/* ═══════════════════════════════════════════════════════════════════════════
   COMPONENTS — the reusable vocabulary of the app
   ───────────────────────────────────────────────────────────────────────────
   Every visual object that appears on more than one page lives here, and
   nowhere else. If you are about to write a block of inline styles, look for
   it here first; if it genuinely doesn't exist, add it here and add it to
   styleguide.html in the same commit.

   Component classes are named for what the thing IS (.stat-tile, .empty-state).
   Classes that only exist for querySelector are named js- or are documented as
   behaviour hooks — never style them. See DESIGN_SYSTEM.md § Naming.
   ═══════════════════════════════════════════════════════════════════════════ */


/* ════ LOGIN ═══════════════════════════════════════════════════════════════ */

#login-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  min-height: 100dvh;
  padding: var(--space-8);
  background: linear-gradient(120deg,
    var(--brand-red) 0%,
    var(--brand-blue) 38%,
    var(--brand-blue) 62%,
    var(--brand-gold) 82%,
    var(--brand-green) 100%);
}
/* max-width, not width — a hard 380px overflows a 375px phone viewport. */
.login-card {
  background: var(--color-surface);
  border-radius: var(--radius-3xl);
  padding: var(--space-12) 40px;
  width: 100%;
  max-width: 380px;
  box-shadow: var(--shadow-login);
  border-top: 6px solid transparent;
  border-image: linear-gradient(90deg,
    var(--brand-red), var(--brand-blue), var(--brand-gold), var(--brand-green)) 1;
}
/* ── The wordmark ────────────────────────────────────────────────────────────
   THE brand mark, and the only one. It had drifted into six treatments — the
   marketing banner's four colours, the app header's white+gold, this login
   screen's red/blue/gold/BLUE, a white "Y" for the PWA icon, an all-white OG
   card and a two-colour video end card. This class is the single definition
   the others now defer to; scripts/make_brand_assets.py renders the same mark
   into the PNG/JPEG surfaces that cannot use CSS.

   Colours cycle red -> blue -> gold -> green, ONE LETTER AT A TIME, so the
   markup needs a span per letter. That is not decoration: the cycle is what
   makes "YardWyre" read as the mark rather than as coloured text.

   The gold and green here are the marketing set (--brand-gold-ink /
   --brand-green-ink), NOT --brand-gold / --brand-green. The darker pair is
   what clears WCAG AA on white; the bright pair stays correct for the
   decorative gradient rules, which are not text. */
.yw-logo {
  font-weight: var(--weight-black);
  letter-spacing: var(--tracking-tight);
  white-space: nowrap;
  text-decoration: none;
}
.yw-logo .c1 { color: var(--brand-red); }
.yw-logo .c2 { color: var(--brand-blue); }
.yw-logo .c3 { color: var(--brand-gold-ink); }
.yw-logo .c4 { color: var(--brand-green-ink); }

/* On the blue app header the mark answers to its ground instead of carrying a
   white chip behind it: the two BLUE letters — which would vanish on blue —
   turn white, and gold/green switch to their brighter (non-ink) variants, since
   the darkened on-light inks read too dim here. It is the SAME wordmark and the
   same per-letter markup — only the colours adapt to the dark ground, which is
   the standard reverse of a colour mark, not a second lockup. The canonical
   .yw-logo (four brand colours, red→blue→gold→green) is unchanged on every
   light ground: login, marketing, styleguide, the generated PNG/JPEG. No
   background here — the mark sits directly on the header.
   (Class name kept for its call sites; it no longer draws a chip.) */
.yw-logo-chip {
  display: inline-flex;
  align-items: center;
}
.yw-logo-chip .c2 { color: var(--color-text-invert); }
.yw-logo-chip .c3 { color: var(--brand-gold); }
.yw-logo-chip .c4 { color: var(--brand-green); }

.login-logo { text-align: center; margin-bottom: var(--space-10); }
.login-logo h1 {
  font-size: var(--text-3xl);
  font-weight: var(--weight-black);
  margin-top: var(--space-5);
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
}
.login-logo p { font-size: var(--text-sm); color: var(--color-text-muted); margin-top: var(--space-1); }
.login-logo .icon { font-size: var(--text-hero); }

/* .form-group is the login form's field. In-app forms use .field (below) —
   they are deliberately different sizes: login is a landing surface, the app
   is dense. */
.form-group { margin-bottom: 18px; }
.form-group label {
  display: block;
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  color: var(--color-text-body);
  margin-bottom: var(--space-2);
}
.form-group input {
  width: 100%;
  padding: var(--space-4) var(--space-6);
  border: 1.5px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  font-size: var(--text-base);
  transition: var(--transition-base);
  outline: none;
}
.form-group input:focus {
  border-color: var(--brand-blue-bright);
  box-shadow: var(--focus-ring);
}
/* The "Remember me" row sits between the last .form-group and the submit
   button, so it carries the same gap .form-group does. It is deliberately not
   a .form-group itself — that rule's `input { width: 100% }` would stretch the
   checkbox across the card. */
#login-form .check-row { margin-bottom: 18px; }
.btn-primary {
  width: 100%;
  padding: 11px;
  background: var(--color-primary);
  color: var(--color-text-invert);
  border: none;
  border-radius: var(--radius-xl);
  font-size: var(--text-md);
  font-weight: var(--weight-semibold);
  cursor: pointer;
  transition: var(--transition-base);
}
.btn-primary:hover { background: var(--color-primary-hover); }
#login-error {
  color: var(--color-danger);
  font-size: var(--text-sm);
  text-align: center;
  margin-top: var(--space-5);
  display: none;
}

/* A one-time value on the login card that the reader copies or keeps: the
   two-step setup key and the recovery codes (#login-mfa, js/052-auth.js).
   Monospace so 0/O and 1/I never need guessing, and a block that WRAPS: in an
   input, a 39-character key scrolls half out of sight on a phone, which is
   exactly where it gets typed. user-select:all makes one tap take the lot. */
.login-secret {
  font-family: var(--font-mono-ui);
  font-size: var(--text-base);
  letter-spacing: var(--tracking-wide);
  text-align: center;
  overflow-wrap: anywhere;
  user-select: all;
  padding: var(--space-4) var(--space-5);
  margin-bottom: var(--space-4);
  background: var(--color-surface-sunken);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
}
/* The two-step setup QR. The image is the server's black-on-white drawing with
   its own quiet zone; this frames it on white whatever the theme, because a
   phone camera needs the contrast, and centres it at a size a camera reads
   from arm's length. Empty (no QR) it takes no room. */
.login-qr {
  display: flex;
  justify-content: center;
  margin-bottom: var(--space-4);
}
.login-qr:empty {
  display: none;
}
.login-qr img {
  width: 200px;
  max-width: 100%;
  height: auto;
  background: var(--white);   /* the primitive, not --color-surface: it must not follow dark mode */
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
}
/* The ten recovery codes, two to a row. No list markers: the codes have no
   order, and a "1." beside a code reads as part of it. */
.login-secret-list {
  list-style: none;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-2) var(--space-6);
}

/* ── The install offer ─────────────────────────────────────────────────────
   The bottom of the sign-in card on BOTH logins, shown by
   js/052c-install-offer.js when it finds a phone without the app. It lives
   here rather than in field.css because both bundles load this file and the
   offer is now one component; field.css may only add what is field-shaped. */
.login-install {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
  margin-top: var(--space-9);
}
/* [hidden] is a bare attribute selector at specificity 0-1-0, so the display
   above beats it unless this rule exists — and the block shows on every
   desktop. Same trap bb41e65 fixed on the banner's Install button. */
.login-install[hidden] { display: none; }

/* A caption ON the hairline rather than a rule with a label beneath it: the
   login card is a centred composition, and this keeps it one. */
.login-install-title {
  display: flex;
  align-items: center;
  gap: var(--space-5);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  color: var(--color-text-dim);
}
.login-install-title::before,
.login-install-title::after {
  content: "";
  flex: 1 1 0;
  border-top: 1px solid var(--color-border);
}

.login-install-actions { display: flex; justify-content: center; gap: var(--space-4); }

/* Equal halves, so neither platform reads as the recommended one.

   `flex: 1 1 0` alone does not get equal halves on a narrow phone. A flex item
   will not shrink below its min-content width, and "Android" is the longer word
   against the wider mark: on a 320px screen the row has 226px for two buttons,
   but the Android side's min-content came to 116 and took its extra 6px off the
   iPhone side. Two side-by-side buttons at 116 and 110 is exactly the kind of
   not-quite-symmetry the eye reads as sloppy without being able to name.

   The horizontal padding is what sets that floor, and it is the cheapest thing
   to give up: the content is centred, so on every width where the halves DO fit
   the padding is invisible — it only ever decides how narrow a half may get.
   At --space-3 the Android side's min-content is 108, under the 113 it is
   entitled to, and the halves come out equal down to 320px.

   These take the login INPUT's shape — 1.5px border, --radius-xl — rather than
   .btn's. They sit directly under the form and belong to it, and staying off
   .btn keeps them clear of the `display` that made a hidden button visible in
   bb41e65.

   The border stays at 1.5px on the FILLED buttons below, drawn in each one's
   own colour rather than removed. Dropping it would shave 3px off both
   dimensions and take these out of step with the inputs directly above. */
.login-install-btn {
  flex: 1 1 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-4);
  padding: var(--space-6) var(--space-3);
  border: 1.5px solid transparent;
  border-radius: var(--radius-xl);
  font-family: inherit;
  font-size: var(--text-base);
  font-weight: var(--weight-semibold);
  /* White on both, so the pair reads as one control with two fills rather than
     as two unrelated buttons. It lives on the base rule and not on each
     modifier because it is now the same value twice — the contrast work that
     earns it is in the tokens, not here. */
  color: var(--color-text-invert);
  cursor: pointer;
  transition: var(--transition-base);
}
/* Only the fill changes per platform. The mark rides on `currentColor`, so it
   turns white with the label — a green robot on a green fill is not a mark, it
   is a hole. */
.login-install-btn--android {
  background: var(--logo-android);
  border-color: var(--logo-android);
}
.login-install-btn--android:hover {
  background: var(--logo-android-hover);
  border-color: var(--logo-android-hover);
}
.login-install-btn--apple {
  background: var(--logo-apple);
  border-color: var(--logo-apple);
}
.login-install-btn--apple:hover {
  background: var(--logo-apple-hover);
  border-color: var(--logo-apple-hover);
}
/* The ring, not a border swap: on a filled button the border is the fill, and
   recolouring it reads as a different button rather than a focused one. */
.login-install-btn:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
}
/* One SLOT, two marks, and both carrying the same amount of INK.

   Sizing is not the same problem as placement, and this rule solves them
   separately.

   Ink first. The two glyphs have opposite proportions: each viewBox in the
   markup is cropped to its measured bounding box, and the robot's is 24×13.522
   while the apple's is 19.55×24. Match their heights and the robot comes out
   nearly twice as wide. Match their boxes at 22×22 and the robot lands 22×12.4
   against the apple's 17.9×22 — rasterising both paths at those sizes and
   counting covered pixels gives 171 against 250, so the apple carries 1.46× the
   ink and plainly reads as the bigger mark. The sizes that match are 26.6×14.98
   for the robot and 17.92×22 for the apple: 250 covered pixels each.

   Placement second. Drawing each at its own ink-tight size is what the row used
   to do, and it left the two marks starting at different distances from their
   button's left edge — the buttons were the same width and their contents were
   centred, yet the row still read as two unrelated buttons, because nothing in
   it lined up across the gap.

   So both marks now get the same 26.6×22 box: the union of the two ink sizes.
   `preserveAspectRatio` is xMidYMid meet by default, so each path fits its own
   axis and centres on the other, landing at EXACTLY the ink-matched sizes
   above — the robot fits by width at 26.6×14.98, the apple by height at
   17.92×22. Identical slots, identical ink, marks aligned across the row.

   The box is also why the gap is honest. `gap` measures from the box edge, and
   with both boxes the same the two labels sit the same 10px from their mark.

   All four numbers come off the cropped viewBoxes, so keep them in step if
   either path is ever replaced: the slot is (widest ink × tallest ink) once the
   two are scaled to equal coverage — aspect × ink, not height. */
.login-install-logo {
  flex: 0 0 auto;
  width: 26.6px;
  height: 22px;
  fill: currentColor;
}

/* Sunken, like every other block of read-only text in the app. Its own
   [hidden] guard for the reason above — this one carries no display, but the
   next person to add one to it should not have to rediscover why. */
.login-install-steps {
  margin: 0;
  padding: var(--space-4) var(--space-5);
  border-radius: var(--radius-lg);
  background: var(--color-surface-sunken);
  font-size: var(--text-xs);
  color: var(--color-text-body);
  line-height: 1.6;
}
.login-install-steps[hidden] { display: none; }

.login-install-why {
  margin: 0;
  text-align: center;
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}


/* ════ BUTTONS ═════════════════════════════════════════════════════════════
   .btn is the shape; a colour class gives it its meaning. Never ship a .btn
   without one of --blue / --outline / --red.
     .btn-blue     the primary action on the surface (max one per toolbar)
     .btn-outline  everything secondary
     .btn-red      destructive, and only destructive
     .btn-sm       the row-level size, for buttons inside table cells         */

.btn {
  padding: 9px var(--space-7);
  border-radius: var(--radius-xl);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  cursor: pointer;
  border: none;
  transition: var(--transition-base);
  /* Pinned for the same reason as the controls in base.css: a button's
     height is a metric, and body's leading must not grow it. */
  line-height: normal;
  /* Removes the ~300ms double-tap-to-zoom delay browsers add on touch.
     A button is never a zoom target, and 300ms of dead time is the
     difference between a control that answers and one that lags. */
  touch-action: manipulation;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  /* Three of these are <a>, not <button> (the storefront's "Open ↗" and the
     two invoice-download links), and they were the only underlined buttons in
     the app. A <button> is never underlined, so this cannot regress one. */
  text-decoration: none;
}
.btn-blue    { background: var(--color-primary); color: var(--color-text-invert); }
.btn-blue:hover { background: var(--color-primary-hover); }
.btn-outline { background: var(--color-surface); color: var(--color-text-body); border: 1.5px solid var(--color-border-strong); }
.btn-outline:hover { border-color: var(--color-border-hover); background: var(--gray-50); }
.btn-red     { background: var(--color-danger); color: var(--color-text-invert); }
.btn-red:hover { background: var(--color-danger-hover); }
.btn-sm      { padding: 5px var(--space-4); font-size: var(--text-xs); }

/* Nothing in the app dimmed a disabled button, so an in-flight "Saving…" and
   a genuinely unavailable action both looked like live controls you could
   keep tapping. Applies to every .btn, not one page's. */
.btn:disabled { opacity: .55; cursor: not-allowed; }

/* In flight: an action that creates, charges or sends, waiting on the server
   (inFlight() in 050b-ui-components.js sets aria-busy and disables it). The
   dimming above says "not now"; the spinner says "working on it", so nobody
   reloads mid-charge thinking the button died. */
.btn[aria-busy="true"] { cursor: progress; }
.btn[aria-busy="true"]::after {
  content: '';
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: var(--radius-round);
  animation: btn-busy-spin .7s linear infinite;
}
@keyframes btn-busy-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  /* A still ring, not a spinning one — the state is still shown. */
  .btn[aria-busy="true"]::after { animation: none; border-right-color: currentColor; opacity: .5; }
}

/* A Tab stop nobody can see is no better than no Tab stop (WCAG 2.4.7). These
   are the div/span/tr controls that navigation.js makes focusable — they take
   the same ring the real controls already use, and only on keyboard focus, so
   a mouse click does not leave an outline behind it. */
.nav-item:focus-visible,
.inline-edit:focus-visible,
.sort-arrow:focus-visible,
.perm-group-t:focus-visible,
tr.clickable:focus-visible,
td.clickable:focus-visible,
.fm-row:focus-visible {
  outline: var(--focus-outline);
  outline-offset: 2px;
}

/* PRESS FEEDBACK. Every button here had a :hover and nothing else, and :hover
   does not exist on touch — so on /field, the surface staff use in the yard,
   a tap was acknowledged only once the server answered. Feedback belongs on
   the press itself, and it has to be instant to read as a response.

   It carries its OWN transition because .btn's shorthand above is a bare
   .2s, which applies to every property: inheriting it would turn the press
   into a slow squeeze. 90ms reads as "pressed", 200ms reads as "sluggish".

   :not(:disabled) so a button that cannot be used does not pretend to react. */
.btn:not(:disabled):active {
  transform: scale(.97);
  transition: transform 90ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  /* NOT "no feedback" — reduced motion asks for a gentler, non-vestibular
     equivalent, so the scale becomes a tint that conveys the same thing. */
  .btn:not(:disabled):active { transform: none; background-image: linear-gradient(rgba(0,0,0,.08) 0 0); }
}

/* One size below .btn-sm, for the action buttons packed into a table row. */
.btn-xs { padding: 3px var(--space-3); font-size: var(--text-2xs); }

/* An icon beside the word. The icon is an inline SVG drawn in currentColor, so
   it follows every variant and theme, sized in em so .btn-sm and .btn-xs scale
   it with their text. The word stays: the icon is aria-hidden decoration, never
   the only label. First used by Share (js/053b-share-part.js). */
.btn-with-icon { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); }
.btn-with-icon svg { width: 1.15em; height: 1.15em; flex: none; }

/* ── NATIVE CONTROLS WEARING .btn-outline ─────────────────────────────────
   The app dresses <select> and <input> elements as outline buttons so a
   toolbar reads as one row of controls. .btn's box is tuned for a text button
   and is too generous for a control that draws its own arrow, so every one of
   these overrode the padding inline — at FOUR different sizes, 34 times.

   Nobody chose four. They accumulated, one toolbar at a time, which is exactly
   what this file exists to stop. The four are preserved here rather than
   converged because collapsing them is a 1-2px change and this migration
   promised not to make those. Converging them is now a one-line decision in
   one place instead of an archaeology exercise across 34 attributes. */
.btn-ctl       { padding: 9px var(--space-5); }                          /* 9x12  */
.btn-ctl-sm    { padding: var(--space-3) var(--space-5); }               /* 8x12  */
.btn-ctl-field { width: 100%; padding: var(--space-3) var(--space-4); }  /* 8x10  */
/* Fourth member of the family: a <select> packed into a table row.

   The font-size is NOT redundant. This family's convention is
   `btn btn-outline btn-ctl*` with no .btn-sm — the .btn-ctl class replaces the
   size rather than combining with it (all 29 existing usages do this). The two
   selects being converted carry .btn-sm today, which is where their 12px comes
   from, so dropping it without restating font-size would grow them to .btn's
   13px.

   Exception: below 900px, responsive.css's iOS-zoom-prevention rule
   (`select { font-size: var(--text-lg) !important; }`) wins over this on any
   <select>, .btn-ctl-xs included — pre-existing, not something this class
   introduced, but worth knowing before "fixing" a size that looks wrong only
   on a narrow viewport. */
.btn-ctl-xs {
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-xs);
}

/* Soft destructive: a red-tinted row action that is not as loud as .btn-red. */
.btn-tint-red { background: var(--color-danger-bg-bold); color: var(--color-danger); }

/* An outline button that means something destructive. The :hover is NOT
   optional — .btn-outline:hover is two classes deep and would otherwise win,
   turning the border grey on hover. It must also be declared after it. */
.btn-outline-red        { color: var(--color-danger); border-color: var(--color-danger); }
.btn-outline-red:hover  { border-color: var(--color-danger); }


/* ════ CARET ═══════════════════════════════════════════════════════════════
   The one arrow. Every "this opens something" control used to end in a typed
   glyph — ▾ ▼ ▸ ▲, whichever the author reached for — and a glyph is at the
   mercy of the font that renders it: different weight, different size,
   different baseline, and never the one the label sits on. Rotating one
   wobbles too, because a character's ink is not centred in its box.

   Drawn instead: two borders of a small square, turned 45°. It takes the
   colour of the control it sits in, so the same class works on an outline
   button, a nav header and a tree row without a variant for each.

   Two rotations, because there are two kinds of disclosure:
     menu or panel  — points down, flips up while open        (.caret)
     tree           — points right, drops down while expanded (.caret-tree)
   Both read state off the control, so the arrow cannot disagree with what is
   on screen the way a hand-written textContent could. */
.caret {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: middle;
  width: 14px;
  height: 14px;
  /* A step behind the label: a caret is a hint, not a second word. */
  opacity: .6;
  transition: transform var(--transition-fast) ease,
              opacity var(--transition-fast) ease;
}
.caret::before {
  content: "";
  width: 6px;
  height: 6px;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  /* The ink of a rotated square lands in the lower half of the box it turns
     in. Lift it onto the centre so every state pivots around one point. */
  transform: translateY(-2px) rotate(45deg);
}
[aria-expanded="true"] > .caret { transform: rotate(180deg); }

/* The tree rules must stay below the menu one: a .caret-tree is also a
   .caret, and these selectors weigh the same, so order is what decides. */
.caret-tree { transform: rotate(-90deg); }
[aria-expanded="true"] > .caret-tree,
.caret-tree.is-open { transform: none; }


/* ════ TOOLBAR & SEARCH ════════════════════════════════════════════════════ */

.toolbar {
  display: flex;
  gap: var(--space-4);
  margin-bottom: var(--space-7);
  align-items: center;
  flex-wrap: wrap;
}
/* A text input in a .toolbar that should not stretch to fill the row — an eBay
   username, a code, a short name. .search-box is the wrong shape here: it owns
   its own padding and magnifier positioning. */
.input-inline { flex: 0 1 320px; min-width: 200px; }

.search-box { position: relative; flex: 1; min-width: 220px; max-width: 400px; }
.search-box input {
  width: 100%;
  padding: 9px var(--space-6) 9px 36px;
  border: 1.5px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  font-size: var(--text-base);
  outline: none;
  background: var(--color-surface);
  transition: var(--transition-base);
}
.search-box input:focus { border-color: var(--brand-blue-bright); box-shadow: var(--focus-ring); }
.search-box .search-icon {
  position: absolute;
  left: 11px; top: 50%;
  transform: translateY(-50%);
  color: var(--color-text-dim);
  font-size: var(--text-md);
}


/* ════ SURFACES ════════════════════════════════════════════════════════════ */

/* .table-card clips its children (so a table's corners follow the card's).
   .card does not: callers that need scrolling set overflow themselves, and
   clipping by default would cut off a dropdown opened inside a panel. */
.table-card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-2xl);
  overflow: hidden;
}
.card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-2xl);
}
/* Standard interior padding for a .card used as a panel rather than a table
   wrapper. Was `style="padding:20px"` on 11 elements. Two tighter steps for
   the slimmer panels — a chart card and a one-line filter bar. */
.card-body    { padding: var(--space-8); }                      /* 20px    */
.card-body-sm { padding: var(--space-7); }                      /* 16px    */
.card-body-xs { padding: var(--space-4) var(--space-6); }       /* 10x14px */

/* A card that reads as a note rather than a panel — the explanatory box above
   a report. It was written as `background: var(--surface-2, rgb(249,250,251))`,
   a token that has never existed, so it silently shipped a fourth off-white
   that no palette change could reach. */
.card-sunken { background: var(--color-surface-sunken); }

/* The bold line that opens a section inside a .card. Seven elements wrote it
   inline as `font-weight:600` plus a margin, six of them on the two storefront
   pages — at three different margins (4px, 6px, 8px), so sibling cards on the
   same screen opened at three different rhythms.

   Not .micro-label: that is the uppercase caption above a read-only VALUE.
   Not .section-divider: that is an uppercase rule BETWEEN sections and draws
   its own border-top. This one just names the card you are looking at. */
.card-title {
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  color: var(--color-text-strong);
  margin-bottom: var(--space-3);
}


/* ════ TABLES ══════════════════════════════════════════════════════════════ */

.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }

/* A row's store, shown only where the Store column is out of reach. Work
   orders, delivery tickets and invoices all number PER STORE, so one number can
   name three real orders; on a desktop the Store column tells them apart, but
   the table is twelve columns wide and on a phone that column sits past the
   right edge of the scroll. This rides inside the number cell, so the store is
   in the first thing a phone shows. Hidden here; responsive.css reveals it at
   640px and below. */
.store-hint { display: none; margin-left: var(--space-2); font-size: var(--text-2xs);
              font-weight: normal; color: var(--color-text-dim); }

table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
thead th {
  background: var(--color-surface-sunken);
  padding: var(--space-4) var(--space-6);
  text-align: left;
  font-size: var(--text-2xs);
  font-weight: var(--weight-bold);
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wider);
  border-bottom: 1px solid var(--color-border);
  white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--color-border-subtle); transition: .1s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--color-surface-sunken); }
tbody td { padding: var(--space-4) var(--space-6); color: var(--color-text-body); vertical-align: middle; }
tbody td.mono { font-family: var(--font-mono); font-size: var(--text-xs); }

/* The row's buttons, as a wrapping strip. It goes INSIDE the cell — never as
   `style="display:flex"` on the <td> itself, which is what the Tenants row did
   and is the whole reason for this class. A flex <td> stops being a table cell,
   and a wrapping flex container's min-content width is only its widest single
   item, so auto layout believes the column fits in one button's width: eleven
   columns and thirteen buttons settled into an 83px Actions column with 558px
   rows and labels broken letter by letter down the cell, while the .scroll that
   should have carried the overflow never engaged — the table had stopped asking
   for the width.

   min-width is what the column claims instead: wide enough for the longest
   label ("eBay Cust# 12345") beside a neighbour, so the table outgrows its card
   and scrolls, which is what .card.scroll and .table-wrap are there for. nowrap
   is the second half — without it a button under pressure still breaks its own
   label rather than pushing the column wider. */
.cell-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
  min-width: 340px;
  /* The ceiling matters under .table-fit, where a column may take its
     max-content width: without it thirteen buttons would ask for one 1,200px
     line and the row would scroll far past anything worth reading. */
  max-width: 380px;
}
.cell-actions .btn { white-space: nowrap; }

/* A small editor living in a cell: an input and its Save on one line, under the
   value they change. Three cells of the Tenants row wrote it inline, property
   for property — the seat cap, the standing discount and the VIN budget.
   flex:none on the button is the point. A flex item may shrink below its own
   content, and in a column this narrow that shrank "Save" to 25px and stacked
   its four letters down the cell. (The inline copies set margin-top:3px; this
   is --space-1, a pixel more, and the row it opens under is the same.) */
.cell-edit { display: flex; gap: var(--space-1); align-items: center; margin-top: var(--space-1); }
.cell-edit .btn { flex: none; white-space: nowrap; }

/* Let a wide table keep its columns at their natural width and scroll inside
   its card, instead of every column being squeezed to whatever is left of the
   card's width. `table { width: 100% }` above is the default and is right for
   most tables; a table with eleven columns is the case it fails — Tenants at
   1,170px gave the Name column 64px and broke a yard's name across three lines
   ("Pull-", "A-Part", "Detr/oit"). min-width keeps it filling the card when the
   columns do not need the room. The scroll comes from the wrapper, so this
   belongs on a table inside a .table-wrap or a .card.scroll and nowhere else.

   It still earns its keep on a table that enableAllColumnControls later takes
   over (js/050i-table-columns.js), even though _applyWidths then sets width
   inline and wins: _seed measures each header FIRST and freezes what it finds,
   so this is the layout those frozen widths are taken from. Removing it would
   hand the column manager the squeezed measurements again. */
.table-fit { width: max-content; min-width: 100%; }

/* ── Resizable / reorderable / hideable columns ──────────────────────────────
   Applied to every eligible table by enableColumnControls (js/050i-table-columns.js), which
   wraps the table in a .table-wrap.cols-managed (or marks the page's own
   .table-wrap .cols-managed) and switches it to
   table-layout:fixed. Only headers are affected; body cells keep wrapping
   within their column. See /styleguide for a live specimen. */
.cols-managed table th.col-managed { position: relative; }
.cols-managed table th.col-managed[draggable="true"] { cursor: grab; }
.cols-managed table th.col-managed.col-dragging { opacity: .5; }
/* Under fixed layout a nowrap header narrower than its text would spill into the
   next cell; clip it to the column instead. Body cells wrap instead of clipping,
   so nothing is hidden. */
.cols-managed table thead th { overflow: hidden; text-overflow: ellipsis; }
/* Wrap even mid-token: a long unbreakable value (an email, a no-space name) must
   stay inside its column. Without this, dragging a column narrower than such a
   value spills its text across the next column, since fixed layout honours the
   width and the cell overflows visibly. */
.cols-managed table tbody td { overflow-wrap: anywhere; }
/* The grab strip on a column's right edge. touch-action:none so a drag resizes
   rather than scrolling the table under it. */
.col-resize {
  position: absolute; top: 0; right: 0; width: 8px; height: 100%;
  cursor: col-resize; user-select: none; touch-action: none;
}
.col-resize:hover { background: var(--color-primary-border); }
/* Where a dragged column would land, drawn on the header it is hovering. */
.col-drop-before { box-shadow: inset 2px 0 0 0 var(--color-primary); }
.col-drop-after  { box-shadow: inset -2px 0 0 0 var(--color-primary); }

/* ---- Rows dragged into a new order ----
   The same three states as a column, turned on their side, for a table whose
   rows can be dragged (the Routing page's stops, js/117-routing.js). The row
   carries draggable="true"; .drag-handle is the grip that says so. Where it
   would land is a line across the row it is over, top or bottom. See
   /styleguide for a live specimen. */
.drag-handle {
  display: inline-block; cursor: grab; user-select: none;
  color: var(--color-text-muted); padding-right: var(--space-1);
}
tr[draggable="true"] { cursor: grab; }
tr.row-dragging { opacity: .5; }
tr.row-drop-before > td { box-shadow: inset 0 2px 0 0 var(--color-primary); }
tr.row-drop-after  > td { box-shadow: inset 0 -2px 0 0 var(--color-primary); }
/* A place in a list, typed: wide enough for three digits and the spinner.
   Input() emits .input-inline too, whose 200px min-width and flex basis are
   sized for forms; both classes together outrank it, as .support-input-row's
   override does. */
.input-inline.input-pos { width: 4.5em; min-width: 0; flex: none; }
/* A money amount typed beside a row -- one picked part's price in the parts
   wizard. Wider than .input-pos for "1250.00"; same override of .input-inline. */
.input-inline.input-money { width: 7em; min-width: 0; flex: none; }
/* A column the user hid from the Columns menu. !important because a page may
   write cell.style.display itself (the house rule, see DESIGN_SYSTEM.md), and
   the user's choice has to outlast that toggle rather than lose to it. */
.cols-managed table .col-off { display: none !important; }
/* The header's menu button. Shown on hover or keyboard focus so it does not sit
   over every header's text; always shown where there is no hover (touch). The
   glyph is CSS content so the header's textContent stays just its label.
   --target-min square, the size the a11y pass gave the sort arrows beside it:
   on a touch screen this is the only way into the menu, and it sits beside
   the resize grip, so a small one was a tap that resized or missed. */
.col-menu-btn {
  position: absolute; top: 50%; right: 10px; transform: translateY(-50%);
  display: inline-flex; align-items: center; justify-content: center;
  min-width: var(--target-min); min-height: var(--target-min);
  border: 0; background: var(--color-surface); color: var(--color-text-dim);
  border-radius: var(--radius-sm); padding: 0;
  font-size: var(--text-sm); line-height: 1; cursor: pointer; opacity: 0;
}
.col-menu-btn::before { content: '\22EE'; }
/* any header, not only th.col-managed: eBay Listings' headers carry the menu
   while keeping their own layout (attachColumnMenus). */
th:hover > .col-menu-btn,
.col-menu-btn:focus-visible,
.col-menu-btn[aria-expanded="true"] { opacity: 1; }
.col-menu-btn:hover { background: var(--color-primary-bg); color: var(--color-text-body); }
.col-menu-btn:focus-visible { outline: var(--focus-outline); outline-offset: 1px; }
@media (hover: none) { .col-menu-btn { opacity: 1; } }
/* The panel it opens: .dropdown-panel's look, fixed to the viewport so the
   header's and the card's overflow cannot clip it. top/left/max-height are
   set from the button's position when it opens and as the page scrolls. */
.dropdown-panel.col-menu { position: fixed; margin-top: 0; }
/* tabindex=-1 so a press on its caption or padding keeps focus in the menu
   (050i); that focus is the container's, not a control's, so no ring. */
.dropdown-panel.col-menu:focus { outline: none; }
/* "Move <column>  ← Left  Right →" — one pair for the column the menu was
   opened from, above the list, so it stays put between clicks. */
.col-menu-move {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-1) var(--space-4) var(--space-2);
  margin-bottom: var(--space-1);
  border-bottom: 1px solid var(--color-border-subtle);
}
.col-menu-move > span {
  flex: 1; min-width: 0; font-weight: var(--weight-semibold);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.col-menu-foot {
  border-top: 1px solid var(--color-border-subtle);
  margin-top: var(--space-1); padding: var(--space-2) var(--space-4) var(--space-1);
}

/* A row of correspondence still waiting on us — eBay Messages' unread
   conversations. Bold alone was doing this job (inline `font-weight:600` on the
   <tr>), and bold is the weakest of the three signals a scanning reader gets:
   the yard's own report on this page was "i dont know the new message from the
   old", which is the question a tint answers from across the room and a
   half-step of weight does not.

   Three carriers again, matching `.msg-item.is-unread` in the thread this row
   opens, so the two surfaces say "unread" the same way: the tint, the accent
   edge, and the weight. The Status cell spells out "New" beside them, so the
   state still survives a greyscale screen and a stylesheet that never loaded.

   The accent hangs off the first CELL, not the row: under
   `border-collapse: collapse` a box-shadow on a <tr> is unreliable across
   browsers, while a cell is an ordinary box. Inset, so it costs no layout and
   the column below it stays aligned with the read rows.

   Order matters — `tbody tr:hover` above scores the same (0,1,2), so this must
   FOLLOW it to win on an unhovered row, and the :hover pair below (0,2,2) puts
   the hover feedback back rather than letting an unread row go dead to the
   pointer.

   `.needs-action` is the SAME shape under a name that is not about mail, for
   the second surface that needed it: an eBay order eBay has not told us has
   shipped. Two names rather than one because `.is-unread` is a lie on an order
   row and `.needs-action` is a lie on a message row, and rather than three more
   declarations because the two states are the same claim — this row is open
   work — and a reader who learns the tint on one page must not have to learn
   it again on the other. Both keep a worded cell beside the tint ("New" there,
   "Not shipped" here) so neither leans on colour alone. */
tbody tr.is-unread,
tbody tr.needs-action { background: var(--color-primary-bg); font-weight: var(--weight-semibold); }
tbody tr.is-unread > td:first-child,
tbody tr.needs-action > td:first-child { box-shadow: inset 3px 0 0 var(--color-primary); }
tbody tr.is-unread:hover,
tbody tr.needs-action:hover { background: var(--color-primary-bg-bold); }

/* A section of the page folded away until it is asked for. Generic on purpose:
   the content is whatever the caller puts inside, unlike .audit-detail below,
   whose grid only makes sense for a <dl> of labelled values.

   First use is the Pricing screen's "Other features" — the rate rows for
   features the tenant in scope is not billed for. They are collapsed rather
   than hidden because a feature_setup fee prices at the rate in force on its
   incurred_at, so setting a price BEFORE the customer switches the feature on
   is the only way it is ever collected.

   The summary is deliberately quiet: it labels something the reader has
   already been told they do not need, so it must not compete with the section
   headings above it. */
.disclosure {
  margin-top: var(--space-5);
  border-top: 1px solid var(--color-border);
  padding-top: var(--space-5);
}
.disclosure > summary {
  cursor: pointer;
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
  color: var(--color-text-dim);
  /* Chrome removes the triangle from a <summary> that has been given any
     `display` other than list-item. Nothing here sets one, but `list-style:
     revert` is what .audit-detail already needed under the app's reset, so the
     same line is needed here for the marker to appear at all. */
  list-style: revert;
}
.disclosure > summary:hover { color: var(--color-text-strong); }
.disclosure > .disclosure-body { margin-top: var(--space-5); }

/* The same fold, as a page section rather than an aside.

   The difference from .disclosure above is what the summary MEANS. There, it
   labels something the reader has already been told they do not need, so it is
   deliberately quiet and must not compete with the headings around it. Here the
   summary IS the heading, so it carries the weight and colour of the
   .card-title / <h4> it replaces. Same mechanism, opposite emphasis — which is
   why this is a variant and not a second component.

   Why it exists: screens stacked every section open at once — 21 unpaged tables
   on Analytics, 13 settings panels on Storefront — so reaching the third one
   meant scrolling past the first two in full. Work Orders learned this first
   (see the comment in pages/003-workorders.html): a fold goes ABOVE the list,
   because as a card under fifty rows it sat 3,000px down and read as missing.

   Always a native <details>. Keyboard support, screen-reader semantics and
   find-in-page auto-expansion come free and survive nothing hand-rolled. */
.disclosure-card {
  /* Undo the parent's in-flow divider: .disclosure separates itself from what
     precedes it with a rule, a card separates itself by being a card. */
  margin-top: 0;
  border-top: 0;
  padding-top: 0;
  margin-bottom: var(--space-6);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-2xl);
  /* Deliberately NOT .table-card's `overflow: hidden`. That clips a table's
     corners to the card, which is right when the card IS the table — but here
     it would also clip a wide `.scroll` table inside the fold once it opens. */
}
.disclosure-card > summary {
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  color: var(--color-text-strong);
  padding: var(--space-5) var(--space-7);
  /* `list-style: revert` is inherited from `.disclosure > summary` and is
     load-bearing: the app's reset removes the native marker, so without it
     there is no triangle and the section does not look openable at all. */
}
.disclosure-card > summary:hover { background: var(--color-surface-sunken); }

/* The summary IS the section heading, so it carries a real one. Converting a
   screen's `.page-header.is-section` blocks into folds turned their <h2>s into
   plain <summary> text, which reads the same and navigates far worse: anything
   moving by headings — a screen reader's heading list, the document outline —
   lost every section on the page at once. <summary>'s content model allows
   heading content, so the heading goes back inside it.

   Restyled to the summary's own type rather than left to inherit a heading
   scale: `font: inherit` takes size, weight and family from the summary above,
   so the fold looks identical to before this line existed. `display: inline`
   keeps it on the marker's line — a block-level h3 would push the text under
   the triangle. */
.disclosure-card > summary > h3 {
  display: inline;
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  margin: 0;
}
.disclosure-card > .disclosure-body {
  margin-top: 0;
  padding: 0 var(--space-7) var(--space-7);
}

/* The row count beside a section heading — "Leads 248". Quiet, because the
   heading is the thing being read; the number only says how much is behind it.
   104-activity.js wrote this inline as "— N actions" before this existed. */
.disclosure-count {
  font-size: var(--text-xs);
  font-weight: var(--weight-normal);
  color: var(--color-text-muted);
  margin-left: var(--space-2);
}

/* ── Pick-photo gallery ────────────────────────────────────────────────────
   The photos the phone takes as a part is ticked, read back on the desktop
   through the Pictures button beside Items on a work order or delivery
   ticket. One picture after another at the width of the modal, under a
   heading per part, so the office scrolls through them the way it would
   through the share folder. They used to be 48px squares inside the
   line-items table's Part type cell, where one photo could throw the whole
   row out of alignment. The list carries .js-photoset so a click opens the
   lightbox and ‹ › step through the document's set; the Save link under each
   picture is a plain <a download>, since the lightbox has no save of its own. */
.pick-gallery {
  display: flex;
  flex-direction: column;
  gap: var(--space-7);
}
.pick-gallery-part {
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  color: var(--color-text-strong);
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--color-border);
}
.pick-gallery-item img {
  display: block;
  width: 100%;
  max-height: 70vh;
  object-fit: contain;
  background: var(--color-surface-sunken);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
  cursor: pointer;
}
.pick-gallery-caption {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  margin-top: var(--space-2);
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}

/* The activity report's per-row forensic panel: collapsed by default, opened
   when a row needs reading rather than scanning. A <dl> because that is what
   it is — labelled values, not a table of rows. */
.audit-detail { margin-top: 6px; }
.audit-detail > summary {
  cursor: pointer;
  font-size: var(--text-xs);
  color: var(--color-text-dim);
  list-style: revert;
}
.audit-detail > dl {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 2px 10px;
  margin: 6px 0 2px;
  font-size: var(--text-xs);
}
.audit-detail dt { color: var(--color-text-dim); }
.audit-detail dd {
  margin: 0;
  font-family: var(--font-mono-ui);
  word-break: break-all;
}

/* Bare `tbody td` was the only cell padding rule, so a <tfoot> totals row
   rendered flush against the cell borders and half the height of the rows
   above it. Give footers the same box as the body, plus the grey surface that
   marks them as a summary rather than one more record. */
tfoot td {
  padding: 11px var(--space-6);
  color: var(--color-text-strong);
  font-weight: var(--weight-bold);
  background: var(--color-surface-sunken);
  border-top: 2px solid var(--color-border);
}

th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { background: #eef2f7; color: var(--color-primary); }
/* A Tab stop since js/050m-key-activation.js. Inset, because .table-wrap
   scrolls and would clip a ring drawn outside the cell. */
th.sortable:focus-visible { outline: var(--focus-outline); outline-offset: -2px; }

/* Frozen columns. The header cell must sit above the body cell of the same
   column, hence two z-index steps rather than one. */
.sticky-col-first { position: sticky; left: 0;  z-index: var(--z-sticky-cell); background: var(--color-surface); }
.sticky-col-last  { position: sticky; right: 0; z-index: var(--z-sticky-cell); background: var(--color-surface); box-shadow: var(--shadow-sticky-col); }
thead .sticky-col-first,
thead .sticky-col-last { background: var(--color-surface-sunken); z-index: var(--z-sticky-head); }
tfoot .sticky-col-first { background: var(--color-surface-sunken); }
/* A frozen cell paints its own opaque background (that's what stops the
   scrolled columns showing through it), which would otherwise leave it white
   while the rest of its row lights up on hover. */
tbody tr:hover .sticky-col-first,
tbody tr:hover .sticky-col-last { background: var(--color-surface-sunken); }

.table-footer {
  padding: var(--space-5) var(--space-7);
  border-top: 1px solid var(--color-border-subtle);
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: #fafafa;
}
.table-footer span { font-size: var(--text-xs); color: var(--color-text-muted); }

/* Determinate progress. Added for the Waiting to Publish run, which paces itself
   in slices so a 500-part batch is visibly moving rather than one silent
   request. The WIDTH is the only inline style on it — a percentage computed from
   data is exactly the one-off rule 3 in docs/CONTRIBUTING.md permits. */
.progress-track {
  height: 6px;
  border-radius: var(--radius-xs);
  background: var(--color-surface-sunken);
  overflow: hidden;
}
.progress-bar {
  height: 100%;
  background: var(--color-primary);
  border-radius: inherit;
  transition: width var(--transition-fast);
}

/* Sort arrows in a table header. Two separate targets rather than one toggling
   caret, because a yard wants "highest price first" in one click and should not
   have to work out which way the current arrow points.
   Side by side, each its own --target-min square (WCAG 2.5.8). Stacked, two
   8px glyphs left each arrow a target about 6px tall. */
.sort-arrows {
  display: inline-flex;
  margin-left: var(--space-1);
  vertical-align: middle;
}
.sort-arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--target-min);
  min-height: var(--target-min);
  font-size: var(--text-2xs);
  line-height: 1;
  cursor: pointer;
  color: var(--color-text-dim);
  user-select: none;
}
.sort-arrow:hover { color: var(--color-text-body); }
.sort-arrow.is-on { color: var(--color-primary); }

.pagination { display: flex; gap: var(--space-1); align-items: center; }

/* The page-size + prev/numbers/next control that sits at the right-hand end of
   a .table-footer. It was the same run of inline flex styles hand-written on
   every paged table; making it a class is what let the Manage eBay Listings
   pager be added without adding any styling at all. */
.table-pager {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-left: auto;
}
.page-btn {
  padding: var(--space-1) var(--space-4);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-md);
  font-size: var(--text-xs);
  cursor: pointer;
  background: var(--color-surface);
  color: var(--color-text-body);
  transition: var(--transition-fast);
}
.page-btn:hover { background: var(--color-surface-hover); }
.page-btn.active { background: var(--color-primary); color: var(--color-text-invert); border-color: var(--color-primary); }

/* The "…" between two non-adjacent page numbers. renderPageNumbers hand-wrote
   this as three inline declarations, one of them a raw colour, on every gap it
   rendered; it is one class now. */
.pager-dots { padding: 0 var(--space-1); color: var(--color-text-muted); }

/* ── EMPTY / LOADING / ERROR STATE ────────────────────────────────────────
   The single most duplicated thing in the app: 79 hand-written copies of
   "centre this, pad it, grey it out" for a table with nothing in it. Use it
   on a <td colspan="N"> inside a table, or on a <div> inside a card.

     <td colspan="6" class="empty-state">No parts found</td>
     <td colspan="6" class="empty-state is-error">Could not load parts</td>

   Beats `tbody td` on specificity, so it overrides the cell padding/colour
   without !important. */
.empty-state {
  text-align: center;
  padding: var(--space-10);
  /* Was --color-text-dim. An empty state is the only text in its region, so it
     is read, not glanced past, and 2.54:1 fails WCAG AA 1.4.3 — there is no
     empty-state exemption. --color-text-muted keeps it visibly secondary at
     4.83:1. */
  color: var(--color-text-muted);
  font-size: var(--text-sm);
}
.empty-state.is-error   { color: var(--color-danger); }
.empty-state.is-compact { padding: var(--space-9); }


/* A second (or third) section heading further down a page that already has
   one — "Part Type Stock Photos" under the ad template, "eBay Stores" under
   the listings table. Was `style="margin-top:28px"` on three of them and
   `32px` on a fourth, none of which was on the spacing scale; this settles on
   --space-10 (32px), so the gap is now one value in one place. */
.page-header.is-section { margin-top: var(--space-10); }


/* Photos a buyer attached to that message. They stay hosted on eBay Picture
   Services — these are <img> tags pointing at eBay's CDN, nothing is copied
   onto us — so the row has to cope with whatever dimensions they uploaded.
   Empty when there are no attachments, which is the usual case. */
.message-media {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-6);
  margin-top: var(--space-6);
}
.message-media:empty { display: none; }
.message-media img {
  max-width: 10rem;
  max-height: 10rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  object-fit: cover;
}


/* ── A CONVERSATION, STACKED ───────────────────────────────────────────────
   The eBay inbox is a list of MESSAGES. A buyer who writes three times appears
   three times with nothing on screen connecting them, and opening any one of
   them shows only itself — so the question is in one row and the follow-up is
   in another, and neither can be read from the other. Reported from the yard
   as "i dont know the new message from the old ... i need to go to the email
   to see them".

   This is the shape that answers it: every message in one conversation in a
   single scrolling column, oldest at the top, so the newest sits nearest the
   reply box and the history above it is what makes the newest make sense.

   THE THREAD SCROLLS, NOT THE MESSAGE. This replaced `.message-body`, a single
   panel with its own 16rem cap, which was right for one message shown alone and
   wrong the moment there were five: five capped panes would each scroll
   independently inside a dialog that also scrolls. One cap, on the column. */
.msg-thread {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
  max-height: 26rem;
  overflow-y: auto;
  padding-right: var(--space-3);   /* keeps the scrollbar off the text */
}

.msg-item {
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--color-border-strong);
  border-radius: var(--radius-lg);
  background: var(--color-surface-sunken);
  padding: var(--space-5) var(--space-6);
}

/* Still unread on eBay. The accent edge is what answers "which of these is
   new?" from across the room — the question that sent the yard to their email
   client to find out. Colour is not the only carrier of it: the head row
   spells out "New" beside the date, so the state survives a greyscale screen
   and a colour-blind reader. */
.msg-item.is-unread {
  border-left-color: var(--color-primary);
  background: var(--color-primary-bg);
}

/* OUR answer, not the buyer's question. eBay files the store's own replies in a
   folder of its own and returns them from no call that does not name it, so for
   a while this shape could not exist and `.msg-item-replied` below stood in for
   it. It exists now, and in a column where every message is a grey panel a
   reply has to be tellable from a question without reading it.

   THREE carriers, and colour is only one of them: the panel indents, the accent
   edge moves to the RIGHT, and the head row says "You" instead of a member
   link. The first two survive a greyscale screen and the third survives a
   stylesheet that never loaded. An outbound message is never also .is-unread —
   it is read by definition — so the two never fight over the left edge. */
.msg-item.is-outbound {
  margin-left: var(--space-8);
  border-left: 1px solid var(--color-border);
  border-right: 3px solid var(--color-success);
  background: var(--color-surface);
}

.msg-item-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
  font-size: var(--text-2xs);
  color: var(--color-text-dim);
}
.msg-item-who {
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  color: var(--color-text-strong);
}
.msg-item-new {
  font-size: var(--text-2xs);
  font-weight: var(--weight-bold);
  color: var(--color-primary-text);
  background: var(--color-primary-bg-bold);
  border-radius: var(--radius-pill);
  padding: 1px 8px;
}

/* Verbatim text from outside the business, so the sender's own line breaks
   stand. Uncapped on purpose: the height limit lives on `.msg-thread` above. */
.msg-item-body {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  font-size: var(--text-sm);
  color: var(--color-text);
}
/* eBay delivers the occasional message with nothing in it — a buyer sending an
   emoji their keyboard did not encode, most often. Saying so beats a blank
   panel that reads as a page that failed to load. */
.msg-item-body:empty::after {
  content: "(this message had no text in it)";
  font-style: italic;
  color: var(--color-text-dim);
}

/* We answered this one, and the answer itself is not on screen. A FALLBACK
   now rather than the main event: the reply usually renders as its own
   `.is-outbound` panel right underneath, and the marker would be repeating what
   the next element already says. It still appears where the sent window does
   not reach back as far as the question does, which is the case it was written
   for — eBay's `Replied` flag is then the only evidence there is. */
.msg-item-replied {
  margin-top: var(--space-3);
  font-size: var(--text-2xs);
  color: var(--color-success-text);
}


/* ════ BADGES ══════════════════════════════════════════════════════════════ */

/* An R-number / stock code chip. */
.r-badge {
  display: inline-block;
  background: var(--color-primary-bg);
  color: var(--color-primary-text);
  font-weight: var(--weight-bold);
  font-size: var(--text-2xs);
  padding: 2px 7px;
  border-radius: var(--radius-xs);
  font-family: monospace;
}

/* Powerlink record status. The suffix is the letter stored in the database,
   which is why these are single letters rather than words. */
.status-badge {
  display: inline-block;
  /* A one- or two-word pill never breaks mid-word, even in a squeezed
     managed-table cell, where td sets overflow-wrap:anywhere. */
  white-space: nowrap;
  padding: 3px 9px;
  border-radius: var(--radius-pill);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  /* Pinned so the pill is the same height wherever it lands. line-height is
     inherited, and td is pinned while a card is not — without this the SAME
     badge would render one height in a table and another in a card. */
  line-height: normal;
}
.status-O { background: var(--color-warn-bg-bold);    color: var(--color-warn-text); }     /* Open      */
.status-C { background: var(--color-success-bg-bold); color: var(--color-success-text); }  /* Closed    */
.status-V { background: var(--color-danger-bg-bold);  color: var(--color-danger-text); }   /* Void      */
.status-P { background: #e0e7ff;                      color: #3730a3; }                    /* Pending   */

/* The same badge worded rather than lettered: the yes/no answer a report is
   there to give ("In stock" / "Not in stock" on Part Search History). Was
   hand-rolled in a template string with raw rgba() and its own radius. */
.status-yes { background: var(--color-success-bg-bold); color: var(--color-success-text); }
.status-no  { background: var(--color-danger-bg-bold);  color: var(--color-danger-text); }
/* The third answer, added for the offline queue: work that is neither done nor
   failed but WAITING. Wearing .status-no would tell a yard hand their scans had
   been rejected when they are simply still in the phone, and .status-yes would
   say they had landed when they have not. The warn tokens already existed. */
.status-warn { background: var(--color-warn-bg-bold); color: var(--color-warn-text); }
/* Two more answers, added for feature requests, where the question is not
   yes/no at all but "where has this got to". Neither existing colour fits:
   .status-warn says "needs attention", which is right for a request nobody has
   read yet but wrong for one already being built, and .status-no is the red of
   a failure — telling a customer their idea was REJECTED when it was simply
   not planned. So blue for work under way, grey for a thread that is closed
   without anything having gone wrong. Both reuse tokens that already exist. */
.status-muted { background: var(--gray-100); color: var(--gray-600); }
/* Blue lives on .status-new below, which landed for eBay Messages in the same
   week and carries the identical pair. Two names for one declaration rather
   than two declarations: the questions differ ("unread" vs "under way") but
   the answer is the same informational blue, and a second copy of the pair is
   how the two drift apart later. */

/* Unread, not un-answered: the state of a message nobody has opened yet, on
   eBay Messages' Status column. The primary tokens rather than
   `.status-warn`'s amber — an unread buyer question is news, not a problem, and
   amber next to the real warnings on the same screen would cry wolf. Deliberately
   the same pair as `.msg-item-new`, the "New" pill inside the thread the row
   opens, so the badge does not change colour on the way in. Read rows carry no
   badge at all: quieting the settled state is half of what makes the unread
   one carry. */
.status-new,
.status-info { background: var(--color-primary-bg-bold); color: var(--color-primary-text); }

/* Which public form a lead arrived from, on the platform-admin Leads table.
   Not .access-chip: that pill is the six-area web-user access palette and its
   colours already mean something else. Signup keeps the green it had, so an
   admin's read of the table does not change under them.

   --color-success-bg / --color-success-text-bold, NOT the -bold / plain pairing
   the names suggest: --color-success-bg-bold is #d1fae5, a different green from
   the #dcfce7 this badge has always used.

   The radius goes 5px -> --radius-xs (4px) deliberately: every other badge in
   this file is already on the token, and one pill a pixel rounder than its
   neighbours was the drift, not the fix. Size and weight are unchanged —
   --text-2xs is 11px and --weight-bold is 700, exactly what was inline.

   .source-suspect marks a lead stored without a verified Turnstile token. */
.source-badge {
  display: inline-block;
  padding: 2px 7px;
  border-radius: var(--radius-xs);
  font-size: var(--text-2xs);
  font-weight: var(--weight-bold);
  white-space: nowrap;
}
.source-signup  { background: var(--color-success-bg);      color: var(--color-success-text-bold); }
.source-contact { background: var(--color-violet-bg);       color: var(--color-violet-text); }
.source-pricing { background: var(--color-primary-bg-bold); color: var(--color-primary-text); }
.source-suspect { background: var(--color-warn-bg-bold); color: var(--color-warn-text); }

/* ── VIN-MATCH CHIPS ───────────────────────────────────────────────────────
   One decoded fact, and whether this version of the part agrees with it:
   "engine size 3.0" in green, "gears is 6 here, the VIN says 8" in amber.

   These are EVIDENCE, not a verdict, and that is why they are a row of small
   chips rather than one badge. The parts wizard recommends a variant off the
   VIN, and a dismantler is entitled to see which facts produced the
   recommendation before booking a part on it — a bare "matches the VIN" asks
   to be trusted, three chips can be checked against the tag.

   Amber and not red on the conflict: a variant that contradicts the decode is
   not an error, it is simply a different car's part, and the row it sits on
   stays selectable — the decode can be wrong, and the person holding the part
   outranks it.

   Not .status-badge (a single-letter Powerlink record state), not
   .access-chip (the six-area web-user palette). Same small-pill shape, a third
   question. */
.vin-chip {
  display: inline-block;
  padding: 1px var(--space-2);
  border-radius: var(--radius-xs);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  white-space: nowrap;
  background: var(--color-success-bg);
  color: var(--color-success-text);
}
.vin-chip-warn { background: var(--color-warn-bg-bold); color: var(--color-warn-text); }

/* ── ACCESS-AREA CHIPS ─────────────────────────────────────────────────────
   A user's access is a SET, so it renders as a row of chips rather than one
   role badge. Not .status-badge: that is a single-letter Powerlink record
   state and its pill is a different size.

   The colours lived as raw hex in two places that had to stay in step -- the
   legend fragment and PERM_LABELS in 091-web-users.js -- and had already
   fallen out of step. Both now read the same tokens, so the legend cannot
   disagree with the chips it explains. */
.access-chip {
  display: inline-block;
  padding: 2px var(--space-3);
  border-radius: var(--radius-pill);
  font-size: var(--text-2xs);
  font-weight: var(--weight-bold);
  white-space: nowrap;
}
.access-inventory { background: var(--color-access-inventory-bg); color: var(--color-access-inventory-text); }
.access-ai-drafts { background: var(--color-access-ai-drafts-bg); color: var(--color-access-ai-drafts-text); }
.access-ebay      { background: var(--color-access-ebay-bg);      color: var(--color-access-ebay-text); }
.access-ops       { background: var(--color-access-ops-bg);       color: var(--color-access-ops-text); }
.access-sales     { background: var(--color-access-sales-bg);     color: var(--color-access-sales-text); }
.access-admin     { background: var(--color-access-admin-bg);     color: var(--color-access-admin-text); }

/* The chip row in a table cell. */
.access-row { display: flex; gap: var(--space-1); flex-wrap: wrap; }

/* One entry in the legend on the Users page: the chip itself, then what it
   means. It used to be a scaled-up chip — the .access-* modifier on this box,
   filling it with the area's colour — which was wrong twice over. A legend has
   to show the mark it explains, and a 12px-tall pill and a 44px-tall pastel
   slab do not read as the same object. And six saturated blocks across a card
   are the loudest thing on a page whose subject is the table above them.

   The tiles also butted straight together, because the two call sites asked
   for a `gap-3` utility that does not exist (the ladder is 1, 2, 4, 5) — so
   the six blocks fused into one striped band. They carry `gap-5` now.

   Colour lives on the nested .access-chip, so the key cannot show a swatch the
   table would never render. */
.access-key {
  align-items: baseline;
  color: var(--color-text-muted);
  display: flex;
  flex-wrap: wrap;
  font-size: var(--text-xs);
  gap: var(--space-2);
  line-height: var(--leading-normal);
}

/* ── NEUTRAL TAG ───────────────────────────────────────────────────────────
   One item in a plain list of labels — a part type, a name, a keyword. The
   fourth pill in the file and the only one that carries NO state: .status-badge
   is a Powerlink record state, .vin-chip is a decode verdict, .access-chip is
   one of six areas. When the thing being listed means nothing beyond its own
   text, none of those apply, and every site that hit this reached for inline
   styles instead — Auction Scout's "gaps" list drew its own 1px outline, and
   js/067-inventory.js and js/085-ai-call-drafts.js each hand-rolled a grey
   pill from raw hex. Same story as .stat-tile's: re-implemented three times
   before anyone named it.

   Outlined rather than filled, because a neutral tag sitting beside a
   .status-badge must not read as a third colour with a meaning you have to
   go and look up.

   Lay a list of them out with .row.row-wrap.gap-1. */
.tag {
  display: inline-block;
  padding: 2px var(--space-3);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-pill);
  font-size: var(--text-xs);
  color: var(--color-text-body);
  white-space: nowrap;
}


/* ════ ALERTS ══════════════════════════════════════════════════════════════
   Inline notices. The three colour sets are the ones already used ad hoc all
   over the app (blue = here is what we found, amber = read this before you
   carry on, red = it failed); this only gives them one shape, so a warning is
   the same object on every page instead of a fresh block of inline styles. */
.alert { border-radius: var(--radius-xl); padding: var(--space-4) var(--space-6); font-size: var(--text-sm); line-height: var(--leading-normal); }
.alert-info  { background: var(--color-primary-bg); border: 1px solid var(--color-primary-border); color: var(--color-primary-text); }
.alert-warn  { background: var(--color-warn-bg);    border: 1px solid var(--color-warn-border);    color: var(--color-warn-text); }
.alert-error { background: var(--color-danger-bg);  border: 1px solid var(--color-danger-border);  color: var(--color-danger); }
/* Success had no shape, so "it worked" was written inline every time. */
.alert-success { background: var(--color-success-bg); border: 1px solid #bbf7d0; color: var(--color-success-text); }

/* An alert promoted to app chrome: full-bleed between the header and the app
   body, rather than a card inside a page. Layout only — the colour, and the
   difference between "here is what we found" and "read this before you carry
   on", stays with .alert-info / .alert-warn. Composed the same way .btn-sm
   composes on .btn. flex-shrink guards its height against .app-body's flex:1. */
.trial-banner {
  align-items: center;
  border-left: 0;
  border-right: 0;
  border-radius: 0;
  display: flex;
  flex-shrink: 0;
  /* At phone widths the message text already soft-wraps to two lines,
     leaving no room for "Manage billing" to fit on one line beside it — the
     button's own label wraps instead ("Manage" / "billing"). Wrapping the
     row drops the button onto its own line intact rather than squeezing it. */
  flex-wrap: wrap;
  gap: var(--space-4);
  justify-content: center;
}

/* ════ BILLING LOCK ════════════════════════════════════════════════════════
   The wall a tenant sees when their trial has run out unpaid. Full-viewport,
   because the point is that the app is not available — a dismissible banner is
   what we had, and it was ignored for as long as anyone cared to ignore it.

   Sibling to .trial-banner above and the other end of the same story: the
   banner counts the trial down, this is what happens when it reaches zero.
   Colour comes from .alert-warn / .alert-error composed onto .billing-lock-panel,
   so "your trial ended" and "your card was declined" look as different here as
   they do anywhere else.

   position:fixed rather than an overlay inside #app: the wall must cover the
   sidebar and header too, and it must survive whatever page happened to be
   open when the lock bit. See --z-billing-lock in tokens.css for why it sits
   below --z-modal. */
.billing-lock {
  align-items: center;
  background: var(--color-surface-sunken);
  bottom: 0;
  display: flex;
  justify-content: center;
  left: 0;
  padding: var(--space-6);
  position: fixed;
  right: 0;
  top: 0;
  z-index: var(--z-billing-lock);
}
.billing-lock-panel {
  border-radius: var(--radius-2xl);
  max-width: 520px;
  padding: var(--space-8);
  text-align: center;
  width: 100%;
}
.billing-lock-title {
  color: var(--color-text-strong);
  font-size: var(--text-xl);
  font-weight: 600;
  margin: 0 0 var(--space-4);
}
.billing-lock-body { margin: 0 0 var(--space-6); }
/* Wraps rather than squeezes, for .trial-banner's reason: at phone widths two
   buttons side by side shrink until their own labels wrap mid-word. */
.billing-lock-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  justify-content: center;
}
.billing-lock-meta {
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  margin: var(--space-6) 0 0;
}

/* The itemised bill on the wall: what this customer is about to be charged,
   line by line, before they type a card number. Left-aligned inside an
   otherwise centred panel on purpose — money in a column reads as a bill, and
   centred figures cannot be scanned down. */
.billing-lock-charges {
  border-top: 1px solid var(--color-border);
  margin: var(--space-6) 0;
  padding-top: var(--space-4);
  text-align: left;
}
.billing-lock-charge {
  display: flex;
  font-size: var(--text-sm);
  gap: var(--space-4);
  justify-content: space-between;
  padding: 3px 0;
}
.billing-lock-charge-total {
  border-top: 1px solid var(--color-border);
  color: var(--color-text-strong);
  font-weight: 600;
  margin-top: var(--space-2);
  padding-top: var(--space-4);
}

/* ════ STAT TILE ═══════════════════════════════════════════════════════════
   The headline number for a report, above the detail table. Three pages had
   re-implemented this inline; it is one component. */
.stat-tile {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-2xl);
  flex: 1;
  min-width: 150px;
  padding: var(--space-7);
}
.stat-tile-label {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
}
.stat-tile-value {
  font-size: var(--text-stat);
  font-weight: var(--weight-black);
  color: var(--color-text-strong);
  margin-top: var(--space-1);
  /* The largest real text in the app (26px) and the one that most needs
     pulling in: at 800 weight the digits already read wide. */
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
}


/* ════ BAR CHART ═══════════════════════════════════════════════════════════
   The dependency-free trend chart. There is no charting library in this app
   and there is not meant to be one: a column of divs whose height is a
   percentage of the tallest value costs nothing, themes itself from the
   tokens, and prints.

   Only the BAR HEIGHT belongs inline — it is computed from the data, which is
   exactly the one-off value DESIGN_SYSTEM.md rule 3 reserves for the call
   site. Everything else here, the colour especially, is the component's.

   Both charts on Platform Analytics use this — the invoice and activity series
   on the Platform tab (js/116-*.js) and the monthly sales series on the Sales
   Report tab (js/093-*.js). */
.bar-chart {
  display: flex;
  align-items: flex-end;
  gap: var(--space-4);
  height: 210px;
  overflow-x: auto;
  padding-top: var(--space-4);
}
.bar-chart-col {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  min-width: 56px;
}
.bar-chart-bar {
  width: 34px;
  border-radius: var(--radius-xs) var(--radius-xs) 0 0;
  background: linear-gradient(180deg, var(--color-primary), var(--color-primary-hover));
}
/* The value above the bar and the period below it. Both are set on the column
   rather than left to the caller so two charts cannot drift apart. */
.bar-chart-value {
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  color: var(--color-text-body);
  white-space: nowrap;
}
.bar-chart-label {
  font-size: var(--text-2xs);
  color: var(--color-text-muted);
  white-space: nowrap;
}
/* Shown in place of the columns when a window has no data at all. Centred by
   the flex parent, so it needs no positioning of its own. */
.bar-chart-empty {
  margin: auto;
  font-size: var(--text-sm);
  color: var(--color-text-dim);
}


/* ════ DANGER ZONE ═════════════════════════════════════════════════════════
   Destructive actions get their own bordered footer well, well away from the
   benign filter buttons they used to sit beside. */
.danger-zone {
  border: 1px solid var(--color-danger-border);
  background: var(--color-danger-bg);
  border-radius: var(--radius-2xl);
  padding: var(--space-6) var(--space-7);
  margin-top: var(--space-7);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-6);
  flex-wrap: wrap;
}
/* The heading carries the warning; the caption stays muted so the
   explanatory sentence stays legible. --color-danger-text (not the solid
   --color-danger fill) is the token tuned for use on a tint — the solid
   colour is too light against --color-danger-bg to read as a warning.
   Owned by the component for the same reason .bar-chart-value/-label are:
   two call sites cannot drift apart if neither hand-rolls its own colour. */
.danger-zone-title {
  font-weight: var(--weight-semibold);
  color: var(--color-danger-text);
}


/* ════ FILTER PILL ═════════════════════════════════════════════════════════
   A checkbox that reads as a toggle chip. Was inline-styled per pill inside a
   template string, so the checked/unchecked colours were recomputed in JS on
   every render and hover feedback was impossible. */
.filter-pill {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  cursor: pointer;
  padding: var(--space-2) var(--space-5);
  border-radius: var(--radius-pill);
  border: 1.5px solid var(--color-border-strong);
  background: var(--color-surface);
  color: var(--color-text-body);
  transition: var(--transition-fast);
}
.filter-pill:hover { border-color: var(--color-border-hover); background: var(--gray-50); }
.filter-pill input { width: 15px; height: 15px; cursor: pointer; accent-color: var(--color-primary); }
.filter-pill:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-bg); color: var(--color-primary); }
.filter-pill:has(input:focus-visible) { outline: var(--focus-outline); outline-offset: 2px; }

/* A checkbox that reads as a toolbar BUTTON — 38px tall so it sits flush in
   the .btn row it lives in, squarer than .filter-pill and with no
   checked-state recolour: these are standing publish settings (mileage in
   title, white-background first photo), not filters, and a row of six
   lighting up primary-blue would make the eBay toolbar shout. Was six
   identical hand-written labels on pages/007-ebay.html. */
.toolbar-toggle {
  display: inline-flex;
  align-items: center;
  gap: 7px;               /* off-scale, kept from the hand-written originals */
  height: 38px;           /* the toolbar's control height — its .btn/select
                             neighbours pin the same 38px inline */
  padding: 0 var(--space-5);
  font-size: var(--text-sm);
  color: var(--color-text-body);
  cursor: pointer;
  white-space: nowrap;
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  background: var(--color-surface);
}


/* ════ CHECK ROW ═══════════════════════════════════════════════════════════
   A plain checkbox or radio with a label beside it, inside a list of options
   (store pickers, photo-status filters, feature access). 24 inline copies of
   the same flex row, at three slightly different gaps. */
.check-row {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: var(--text-sm);
  color: var(--color-text-body);
  cursor: pointer;
}
/* A denser .check-row for the deep category and fitment trees, where 13px rows
   cost real vertical space. Always used WITH .check-row — this only overrides
   the two properties that change. */
.check-row-sm { gap: var(--space-2); font-size: var(--text-xs); }

/* The larger, card-like variant used where the option needs a description
   under it and the whole box is the click target. */
.check-card {
  display: flex;
  align-items: flex-start;
  gap: var(--space-4);
  padding: var(--space-5);
  border: 2px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  cursor: pointer;
}
.check-card:hover { border-color: var(--color-border-hover); }
.check-card:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-bg); }


/* ════ LISTS & DETAIL TEXT ═════════════════════════════════════════════════ */

/* A bordered well holding a vertical list of options (feature access, stores). */
.option-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-4) var(--space-5);
  background: var(--gray-50);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
}

/* A reorderable row in the eBay column / button preference panels. */
.pref-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) 0;
  border-bottom: 1px solid var(--color-border-subtle);
}

/* A secondary filter line above a table — tighter than .toolbar. */
.filter-bar {
  display: flex;
  align-items: center;
  gap: var(--space-5);
  margin-bottom: var(--space-5);
  flex-wrap: wrap;
}

/* Free text under a subject line (issue details). Preserves the author's
   own line breaks, which is why it is not just .text-xs.text-muted. */
.detail-text {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  white-space: pre-wrap;
  margin-top: 3px;
}

/* A truncating table cell — notes columns that must not stretch the table. */
.cell-notes {
  max-width: 180px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--color-text-muted);
}

/* The small print under a form control. Helper copy, so it takes the two
   tokens tokens.css documents for helper copy: --text-xs ("helper text") and
   --color-text-muted ("secondary / helper copy"). It previously took --text-2xs
   ("uppercase micro-labels, table heads") at 11px and --color-text-dim, which
   is 2.54:1 on white and fails WCAG AA 1.4.3 for body text — tokens.css says so
   itself in the prefers-contrast block. --color-text-muted is 4.83:1 and passes. */
.field-hint {
  display: block;
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  margin-top: var(--space-1);
}

/* Login-screen link and status line. */
.login-link { font-size: var(--text-sm); color: var(--brand-blue-bright); text-decoration: none; }
.login-msg  { margin-top: var(--space-4); font-size: var(--text-sm); text-align: center; }


/* ════ MICRO LABEL ═════════════════════════════════════════════════════════
   The tiny uppercase caption above a read-only value in a detail panel.
   13 inline copies. Not the same thing as .field label, which belongs to an
   editable control. */
.micro-label {
  display: block;
  font-size: var(--text-2xs);
  color: var(--color-text-dim);
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  margin-bottom: var(--space-1);
}


/* ════ DIVIDER ═════════════════════════════════════════════════════════════ */
.divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-6) 0; }


/* ════ DROPDOWN ITEM ═══════════════════════════════════════════════════════
   A row inside a typeahead results panel.

   The panel itself deliberately has NO class. Its three instances are shown and
   hidden by JS writing element.style.display, so a class carrying display:none
   would be overridden by the first toggle and is worse than useless. Give the
   panel a class only in the same change that converts those toggles to
   classList.toggle('open') — see DESIGN_SYSTEM.md. */
.dropdown-item {
  padding: var(--space-3) var(--space-5);
  cursor: pointer;
  font-size: var(--text-sm);
  border-bottom: 1px solid var(--color-border-subtle);
}
.dropdown-item:last-child { border-bottom: none; }
.dropdown-item:hover { background: var(--color-primary-bg); }

/* A checkbox row inside a multi-select panel (stores, photo statuses).
   NOT .check-row: that is 7px gap and sets its own font-size and colour,
   which these inherit from the panel. */
.dropdown-option {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
  padding: 5px var(--space-4);
}
/* The same picker, built a second time on the inventory page at different
   metrics. Both are preserved exactly; converging them is a real (small)
   pixel change, and the point is that the choice is now visible in one file
   instead of buried in two template strings. */
.dropdown-option-plain {
  display: block;
  padding: var(--space-1) 0;
  font-size: var(--text-sm);
  cursor: pointer;
}

/* "Loading part types…" / "No matching part type" inside a typeahead panel.
   Not .empty-state — that is centred and 32px, for a whole empty table. */
.dropdown-empty {
  padding: var(--space-4);
  color: var(--color-text-dim);
  font-size: var(--text-sm);
}
/* The same idea inside a results list rather than a dropdown. */
.list-note { color: var(--color-text-dim); padding: var(--space-2); }

/* ── Multi-select dropdown: wrap / trigger / panel ─────────────────────────
   A button that opens a floating panel of .dropdown-option tick-boxes. Built
   for the Part Search History filters, where the lists are 123 part types and
   32 Powerlink users — far past what a row of .filter-pill chips can show.

   On the panel having a class at all: the note above (and the styleguide) says
   to leave it classless because a class carrying `display:none` loses to the
   JS that toggles element.style.display. That hazard is about `display`
   specifically, and this class does not set it — display stays inline, exactly
   as DESIGN_SYSTEM.md requires. Everything here is the panel's LOOK, which the
   six older panels each re-inline: 8 declarations and 2 raw hexes apiece.
   Converting those is a separate change (it touches the eBay page and the
   shell); this class is what it converts TO.

   --shadow-dropdown and --z-dropdown were added to tokens.css for these panels
   and had no class to live in until now. */
.dropdown-wrap { position: relative; }

/* The trigger's min-width is the point, not decoration: the label swaps between
   "All part types" and "7 part types", and without a floor the button resizes
   on every tick and shoves the rest of the filter row sideways — the same bug
   the sort <select> on that page carries a min-width to avoid. */
.dropdown-trigger { min-width: 170px; }
.dropdown-trigger > .flex-1 { text-align: left; }

.dropdown-panel {
  position: absolute;
  top: 100%;
  left: 0;
  margin-top: 2px;
  background: var(--color-surface);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-dropdown);
  z-index: var(--z-dropdown);
  padding: var(--space-2) var(--space-1);
  min-width: 240px;
  /* 123 part types do not fit on a screen. Cap and scroll inside the panel so
     the page itself never grows a second scrollbar. */
  max-height: 320px;
  overflow-y: auto;
}
/* The "Select all" row that heads a panel: same geometry as a .dropdown-option,
   set apart by weight and a rule rather than by colour. */
.dropdown-select-all {
  font-weight: var(--weight-semibold);
  border-bottom: 1px solid var(--color-border-subtle);
  margin-bottom: var(--space-1);
}


/* ════ MODAL ═══════════════════════════════════════════════════════════════ */

.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,.45);
  z-index: var(--z-modal);
  display: none;
  align-items: center;
  justify-content: center;
  padding: var(--space-8);
}
.modal-overlay.open { display: flex; }
.modal {
  background: var(--color-surface);
  border-radius: var(--radius-3xl);
  width: 100%;
  max-width: 680px;
  max-height: 90vh;
  display: flex;
  flex-direction: column;
  box-shadow: var(--shadow-modal);
}
.modal-lg { max-width: 860px; }
.modal-sm { max-width: 420px; }
.modal-xs { max-width: 380px; }
.modal-header {
  padding: var(--space-8) var(--space-9) var(--space-7);
  border-bottom: 1px solid var(--color-border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-shrink: 0;
}
/* 16px is not display type: leading tightens a little, tracking stays at 0.
   Negative tracking down here would cost legibility, not buy presence. */
.modal-header h3 { font-size: var(--text-lg); font-weight: var(--weight-bold); color: var(--color-text-strong); line-height: var(--leading-snug); }
/* A modal whose whole purpose is destructive — voiding a work order, deleting
   a tenant. The tint is the warning; the buttons inside still follow the
   normal rules. Two classes deep so it beats .modal-header and its h3. */
.modal-header.is-danger    { background: var(--color-danger-bg); border-bottom: 1px solid var(--color-danger-border); }
.modal-header.is-danger h3 { color: var(--color-danger-text); }
/* The lead paragraph and the helper line inside a modal body. */
.modal-lead { font-size: var(--text-base); color: var(--color-text-body); margin-bottom: var(--space-7); }
.modal-hint { font-size: var(--text-xs);   color: var(--color-text-muted); margin: 0 0 var(--space-3); }
.modal-close {
  background: none;
  border: none;
  font-size: var(--text-2xl);
  cursor: pointer;
  color: var(--color-text-dim);
  line-height: 1;
  padding: 2px var(--space-2);
  border-radius: var(--radius-xs);
  transition: var(--transition-fast);
}
.modal-close:hover { background: var(--color-surface-hover); color: var(--color-text-body); }
.modal-body { padding: var(--space-8) var(--space-9); overflow-y: auto; flex: 1; }
.modal-footer {
  padding: var(--space-7) var(--space-9);
  border-top: 1px solid var(--color-border);
  display: flex;
  justify-content: flex-end;
  gap: var(--space-4);
  flex-shrink: 0;
}

/* ── Lightbox ──────────────────────────────────────────────────────────────
   Full-screen photo viewer: tapping ANY thumbnail in the app opens the picture
   here and steps through its siblings with ‹ ›, instead of dumping the raw file
   into a new browser tab (which lost the set, and on a phone left the yard with
   a stray tab to close). Its own overlay rather than .modal: the image wants the
   whole screen and a dark surround, not a padded white card. Ships in both
   bundles — the field app is where most photos are taken and viewed.

   z-index: its own layer, NOT --z-modal. Photos are opened FROM modals (an
   eBay message attachment, a pending upload preview), so at equal z-index
   paint order fell back to manifest tree order — #ebay-message-modal happens
   to land after #lightbox in the built bundle, so it painted on top and the
   lightbox's ‹ › ✕ and click-to-dismiss were all covered and dead underneath
   it. --z-lightbox outranks every modal on purpose, not by manifest luck. */
.lightbox-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,.92);
  z-index: var(--z-lightbox);
  display: none;
  align-items: center;
  justify-content: center;
}
.lightbox-overlay.open { display: flex; }
.lightbox-img {
  max-width: 94vw;
  max-height: 84vh;
  object-fit: contain;
  border-radius: var(--radius-md);
  /* The image is the hit target for nothing — clicks on the surround close. */
  pointer-events: none;
}
/* Arrows sit over the surround, thumb-reachable at the screen edges on a phone. */
.lightbox-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background: rgba(0,0,0,.55);
  color: var(--color-text-invert);
  border: none;
  font-size: var(--text-3xl);
  line-height: 1;
  padding: var(--space-6) var(--space-5);
  cursor: pointer;
  border-radius: var(--radius-md);
}
.lightbox-nav:hover { background: rgba(0,0,0,.8); }
.lightbox-prev { left: var(--space-5); }
.lightbox-next { right: var(--space-5); }
.lightbox-close {
  position: absolute;
  top: var(--space-5);
  right: var(--space-5);
  background: rgba(0,0,0,.55);
  color: var(--color-text-invert);
  border: none;
  font-size: var(--text-2xl);
  line-height: 1;
  padding: var(--space-3) var(--space-5);
  cursor: pointer;
  border-radius: var(--radius-md);
}
.lightbox-close:hover { background: rgba(0,0,0,.8); }
.lightbox-caption {
  position: absolute;
  bottom: var(--space-6);
  left: 0;
  right: 0;
  text-align: center;
  color: var(--color-text-invert);
  font-size: var(--text-sm);
  pointer-events: none;
}

/* ── Page help ─────────────────────────────────────────────────────────────
   The body of the header "?" modal, written by renderHelpBody(). dt/dd rather
   than a two-column table because the label has to wrap on a phone — this
   modal renders in the field bundle too. */
.help-section {
  font-size: var(--text-2xs);
  font-weight: var(--weight-bold);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wider);
  color: var(--color-text-muted);
  margin: var(--space-7) 0 var(--space-4);
}
.help-list { margin: 0; }
.help-list dt {
  font-size: var(--text-sm);
  font-weight: var(--weight-bold);
  color: var(--color-text-strong);
  margin-top: var(--space-4);
}
.help-list dd {
  font-size: var(--text-sm);
  color: var(--color-text-body);
  margin: 2px 0 0;
}
.help-tips {
  margin: 0;
  padding-left: var(--space-7);
  font-size: var(--text-sm);
  color: var(--color-text-body);
}
.help-tips li { margin-bottom: var(--space-2); }


/* ════ FORMS ═══════════════════════════════════════════════════════════════ */

.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-6); margin-bottom: var(--space-6); }
.form-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
.form-row.col-1  { grid-template-columns: 1fr; }

.field label {
  display: block;
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  color: var(--color-text-body);
  margin-bottom: 5px;
}
.field input, .field select, .field textarea {
  width: 100%;
  padding: var(--space-3) var(--space-5);
  border: 1.5px solid var(--color-border-strong);
  border-radius: var(--radius-lg);
  font-size: var(--text-sm);
  outline: none;
  background: var(--color-surface);
  transition: var(--transition-base);
  font-family: inherit;
}
.field input:focus, .field select:focus, .field textarea:focus {
  border-color: var(--brand-blue-bright);
  box-shadow: var(--focus-ring);
}
/* Checkboxes must stay their natural size — the width:100%/padding above is for
   text inputs and would otherwise stretch a checkbox across the row and shove
   its label off to the side (Feature access, Part Duplicator, etc.). */
.field input[type=checkbox] { width: auto; padding: 0; margin: 0; flex: 0 0 auto; }
.field input[type=checkbox]:focus { box-shadow: none; }
/* Trap for later: this selector's specificity beats .checkbox-md's regardless
   of source order, and it resets width but not height — so a Check() call
   that ever lands inside a .field would get .checkbox-md's 16px height with
   this rule's auto width, a lopsided box. No current Check() call site is
   inside a .field, so this is latent, not live. */
.field textarea { resize: vertical; min-height: 70px; }
/* A file picker's own width (its button plus "No file chosen", at the 16px
   responsive.css forces on phones) is about 300px, and as a flex item its
   min-width:auto would not let it shrink: the Account page's photo picker ran
   10px past a 320px screen and the whole page scrolled sideways. Not scoped to
   .field, because that picker, like most, sits in a plain row. */
input[type=file] { max-width: 100%; min-width: 0; }

/* A bare input outside a .field wrapper — inside a table cell, or beside a
   button in a search row. Was 40+ inline copies at four near-identical sizes;
   these are the two that matter. */
.input {
  width: 100%;
  padding: 9px var(--space-5);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  font-size: var(--text-sm);
  outline: none;
  background: var(--color-surface);
  font-family: inherit;
}
.input:focus { border-color: var(--brand-blue-bright); box-shadow: var(--focus-ring); }
.input-sm {
  width: 100%;
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
  outline: none;
  background: var(--color-surface);
  font-family: inherit;
}
.input-sm:focus { border-color: var(--brand-blue-bright); box-shadow: var(--focus-ring); }

/* A <label> that WRAPS its own control, rather than sitting beside one in a
   .field. Lighter than .form-group label (600 weight) and larger than
   .field label (12px) — a third size, because it labels a whole stacked row. */
.stacked-label {
  display: block;
  margin-bottom: var(--space-4);
  font-size: var(--text-sm);
  color: var(--color-text-body);
}

/* The description line under a radio option, or under a modal title. */
.option-note {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  margin-top: 2px;   /* off-scale, matching base.css .page-title p */
}

/* The "Show: 50 / 100 / 200" page-size select that sits in a table footer. */
.select-mini {
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--text-xs);
}

/* A text/number input inside a table cell — smaller than .input-filter, which
   is tuned for a toolbar. Seven cells wrote this by hand, at two border
   colours and two names. */
.input-inline {
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-xs);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xs);
}

/* The leading caption of a stacked tool row — "Margin over Retail Price:",
   "Decrease price by:". The fixed min-width is the point: it is what makes the
   controls of every row line up down the card, so the rows read as one form
   rather than four. Four identical copies were hand-written on the eBay page's
   Bulk Pricing Tools; a fifth row (Decrease price by) is what turned the shape
   into a class, the same way a sixth toggle produced .toolbar-toggle. */
.tool-label {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  white-space: nowrap;
  min-width: 150px;
}

/* A bare filter input in a toolbar. Its WIDTH stays inline on purpose: these
   are per-column measurements (80px for a year, 90px for a price), which is
   rule 3's definition of a legitimate inline style. */
.input-filter {
  padding: 7px var(--space-4);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
}

/* Bigger than a browser default checkbox, smaller than the 20px eBay row box —
   for checkboxes in a dropdown or an options list. */
/* The app's checkbox. Row-selection boxes rendered at three sizes before this
   — browser default (~13px), this 16px, and an inline 18px — so this is now
   the only one. accent-color and the focus ring converge on what .filter-pill already
   does for its own checkbox; tokens.css is explicit that a second focus style
   is "an accessibility bug, not a style choice." */
.checkbox-md {
  width: var(--space-7);
  height: var(--space-7);
  cursor: pointer;
  accent-color: var(--color-primary);
  /* .check-row is display:flex, so this box is a flex item. Without this it
     inherits flex-shrink:1 and a label that WRAPS squeezes it to its 13px
     intrinsic width while the height stays 16px — a visibly lopsided box.
     Seen on the vehicle-intake title attestation, whose wording runs to four
     lines on a phone. .field input[type=checkbox] above already does this. */
  flex: 0 0 auto;
}
/* :not(.filter-pill *) keeps this from stacking a second, concentric ring on
   top of .filter-pill:has(input:focus-visible)'s own ring below — the pill's
   ring (around the whole chip) is the better indicator anyway, since the chip
   is the actual click target, not just the 15px box .filter-pill input draws. */
.checkbox-md:focus-visible:not(.filter-pill *) { outline: var(--focus-outline); outline-offset: 2px; }

/* A table cell you can click to edit in place. The dashed transparent border
   reserves the space so the cell does not jump when it becomes editable. */
.inline-edit {
  cursor: pointer;
  display: block;
  padding: 2px var(--space-1);
  border-radius: var(--radius-xs);
  min-height: 18px;
  border: 1px dashed transparent;
}
/* The hover edge used to be four onmouseenter/onmouseleave attribute pairs on
   every inline-edit cell, setting this.style.borderColor to a hardcoded
   #93c5fd and back to transparent. On the inventory grid that is EIGHT event
   attributes per row - 40,000 of them at the 5,000-row page size, and 4 of the
   9 raw hex values in that row template. It was always a hover state; CSS does
   it once for every one of them. --color-primary-border-bold IS #93c5fd. */
.inline-edit:hover { border-color: var(--color-primary-border-bold); }

/* The "click to add" prompt in an empty inline-edit cell. Was an inline
   style="color:#d1d5db;font-size:11px" repeated twice per row. */
.inline-edit-empty {
  color: var(--color-text-dim);
  font-size: var(--text-2xs);
}

.section-divider {
  font-size: var(--text-xs);
  font-weight: var(--weight-bold);
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wider);
  padding: var(--space-6) 0 var(--space-3);
  border-top: 1px solid var(--color-border-subtle);
  margin-top: var(--space-2);
}


/* ════ RICH TEXT EDITOR ════════════════════════════════════════════════════
   Used for the eBay ad description, which is HTML on the wire but must read
   as formatted text (not tags) to the person editing it. */

.rte { border: 1.5px solid var(--color-border-strong); border-radius: var(--radius-xl); overflow: hidden; background: var(--color-surface); }
.rte-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 3px;
  padding: 5px var(--space-2);
  background: var(--gray-50);
  border-bottom: 1px solid var(--color-border);
}
.rte-toolbar button {
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  padding: 3px 7px;
  font-size: var(--text-sm);
  line-height: 18px;
  color: var(--color-text-body);
  cursor: pointer;
  min-width: 26px;
}
.rte-toolbar button:hover { background: var(--color-border); }
.rte-toolbar button.on {
  background: var(--color-primary-bg-bold);
  border-color: var(--color-primary-border-bold);
  color: var(--color-primary-text-bold);
}
/* Two classes deep on purpose — the generic .field select/.field input rules
   above would otherwise stretch every toolbar control to full width. */
.rte .rte-toolbar select {
  width: auto;
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  padding: 3px var(--space-1);
  font-size: var(--text-xs);
  background: var(--color-surface);
  color: var(--color-text-body);
  max-width: 120px;
}
.rte .rte-toolbar input[type=color] {
  width: 26px; height: var(--space-9);
  padding: 0;
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  background: var(--color-surface);
  cursor: pointer;
}
.rte-sep { width: 1px; align-self: stretch; background: var(--color-border); margin: 2px 3px; }
.rte-editor {
  min-height: 180px;
  max-height: 320px;
  overflow-y: auto;
  padding: var(--space-4) var(--space-5);
  font-size: var(--text-sm);
  line-height: var(--leading-relaxed);
  outline: none;
}
.rte-editor:focus { box-shadow: inset 0 0 0 2px rgba(54,101,243,.15); }
.rte-editor img   { max-width: 100%; }
.rte-editor table { max-width: 100%; }
/* The global `*{margin:0;padding:0}` reset strips list indentation, which put
   the markers outside the editor's content box where overflow-y clipped them —
   so both toolbar list buttons produced lists that looked broken in here. eBay
   styles the published HTML itself, so this only ever affected the editor. */
.rte-editor ul, .rte-editor ol { padding-left: 1.6em; margin: var(--space-2) 0; }
.rte .rte-source {
  display: none;
  width: 100%;
  min-height: 180px;
  max-height: 320px;
  padding: var(--space-4) var(--space-5);
  border: none;
  border-radius: 0;
  outline: none;
  box-shadow: none;
  font-family: var(--font-mono-ui);
  font-size: var(--text-xs);
  line-height: var(--leading-relaxed);
  resize: vertical;
}
.rte.source-mode .rte-editor { display: none; }
.rte.source-mode .rte-source { display: block; }
.rte.source-mode .rte-toolbar button:not(.rte-source-btn),
.rte.source-mode .rte-toolbar select,
.rte.source-mode .rte-toolbar input { opacity: .4; pointer-events: none; }


/* ════ PARTS PANEL ═════════════════════════════════════════════════════════
   The line-item picker shared by work orders, invoices, and delivery tickets. */

.parts-search-row { display: flex; gap: var(--space-3); margin-bottom: var(--space-4); }
.parts-search-row input {
  flex: 1;
  padding: var(--space-3) var(--space-5);
  border: 1.5px solid var(--color-border-strong);
  border-radius: var(--radius-lg);
  font-size: var(--text-sm);
  outline: none;
}
.parts-search-row input:focus { border-color: var(--brand-blue-bright); }
.parts-results {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
  max-height: 180px;
  overflow-y: auto;
  margin-bottom: var(--space-5);
  display: none;
}
.part-result-item {
  padding: 9px var(--space-6);
  cursor: pointer;
  font-size: var(--text-sm);
  border-bottom: 1px solid var(--color-border-subtle);
  transition: .1s;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.part-result-item:last-child { border-bottom: none; }
.part-result-item:hover { background: var(--color-primary-bg); }
.part-result-item .part-name  { font-weight: var(--weight-medium); color: var(--color-text-strong); }
.part-result-item .part-meta  { font-size: var(--text-2xs); color: var(--color-text-muted); margin-top: 1px; }
.part-result-item .part-price { font-weight: var(--weight-bold); color: var(--color-success-deep); font-size: var(--text-sm); white-space: nowrap; margin-left: var(--space-5); }

/* overflow-x:auto rather than hidden — this 7-column line-item table sits
   inside a modal and would otherwise be clipped, not scrollable, on a phone. */
.parts-table {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  margin-bottom: var(--space-3);
}
.parts-table table { font-size: var(--text-xs); }
.parts-table thead th { padding: var(--space-3) var(--space-4); }
.parts-table tbody td { padding: 7px var(--space-4); }

.total-bar { display: flex; justify-content: flex-end; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-1); }
.total-bar span   { font-size: var(--text-sm); color: var(--color-text-muted); }
.total-bar strong { font-size: var(--text-xl); color: var(--color-text-strong); }

/* A money amount drawn in the price green, inline in a row or a sentence --
   an invoice total, a store's price in a list. Colour only: the caller sets the
   weight (fw-semibold in a table cell). The two builders that drew it wrote
   #059669 inline and kept it after the token moved to clear WCAG AA. */
.price-figure { color: var(--color-success-deep); }


/* ════ TOAST ═══════════════════════════════════════════════════════════════ */

#toast-container {
  position: fixed;
  top: var(--space-8); right: var(--space-8);
  z-index: var(--z-toast);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.toast {
  padding: var(--space-5) 18px;
  border-radius: var(--radius-xl);
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
  box-shadow: var(--shadow-toast);
  animation: slideIn .3s ease;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-width: 280px;
}
.toast-success { background: var(--color-success-bg-bold); color: var(--color-success-text); border-left: 4px solid var(--color-success-deep); }
.toast-error   { background: var(--color-danger-bg-bold);  color: var(--color-danger-text);  border-left: 4px solid var(--color-danger); }
/* An error toast stays until it is clicked away (toast() in 051). */
.toast-error   { cursor: pointer; }
/* `info` had no rule at all, so it drew the base .toast with no fill and no
   rail — a floating shadow with unreadable inherited text. Same three-token
   shape as the two above, on the primary ramp. */
.toast-info    { background: var(--color-primary-bg-bold); color: var(--color-primary-text); border-left: 4px solid var(--color-primary); }

@keyframes slideIn {
  from { transform: translateX(120%); opacity: 0; }
  to   { transform: translateX(0);    opacity: 1; }
}

/* ════ NOTIFICATIONS ═══════════════════════════════════════════════════════
   The popup card shares #toast-container with .toast — same corner, same
   stack — so a toast and a notification queue up instead of drawing on top of
   each other. It is a card rather than a toast because it persists longer,
   is dismissible, and is clickable through to the page it is about. */

.notif-card {
  padding: var(--space-4) var(--space-5);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-toast);
  animation: slideIn .3s ease;
  background: var(--color-surface);
  border-left: 4px solid var(--color-primary);
  min-width: 280px;
  max-width: 360px;
  cursor: pointer;
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  /* Dragged to the right to dismiss (120-notifications.js): horizontal is
     ours, vertical stays the browser's so a thumb can still scroll past the
     stack. */
  touch-action: pan-y;
  user-select: none;
}
/* The three states of that drag. While the pointer is down the card tracks
   it, so no transition; let go short of the threshold and it settles back;
   past it, it leaves the way it came in -- slideIn's own far edge -- and the
   script removes it on transitionend. */
.notif-card.is-swiping { transition: none; }
.notif-card.is-settling { transition: transform .2s ease, opacity .2s ease; }
.notif-card.is-swiped {
  transform: translateX(120%);
  opacity: 0;
  transition: transform .25s ease-in, opacity .25s ease-in;
  pointer-events: none;
}

/* Both of the above enter with slideIn, which travels 120% of their own width
   across the screen — the kind of large, fast, edge-of-vision movement that
   reduced motion exists to suppress.

   The fade STAYS. Reduced motion asks for a gentler equivalent, not silence,
   and these two carry the only confirmation the user gets that something
   saved or arrived; removing the animation entirely would make them appear
   with no transition at all, which reads as a glitch rather than a message.
   Only the travel goes, so the timing and easing are inherited untouched.

   Placed AFTER both rules on purpose: same specificity, so an override sitting
   above .toast would lose to it and quietly do nothing. */
@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .toast, .notif-card { animation-name: fadeIn; }
  /* The swipe exit is the same travel in reverse; keep only its fade. The
     script's timeout removes the card whether or not transitionend fires. */
  .notif-card.is-swiped { transform: none; transition: opacity .25s ease-in; }
}
/* A <button>, not a div — the card's action has to be reachable without a
   mouse. These declarations strip the native button chrome so it looks exactly
   as it did as a div; only the focus ring is new. */
.notif-card-text {
  flex: 1; min-width: 0;
  appearance: none; -webkit-appearance: none;
  background: none; border: 0; padding: 0; margin: 0;
  font: inherit; color: inherit; text-align: left; cursor: pointer;
}
.notif-card-text:focus-visible { outline: var(--focus-outline); outline-offset: 2px; }
/* display:block because these are now <span>s inside a <button> (a button
   takes phrasing content, not divs) and still have to stack. */
.notif-card-title { display: block; font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--color-text-strong); }
.notif-card-body  { display: block; font-size: var(--text-xs); color: var(--color-text-muted); margin-top: 2px; overflow-wrap: anywhere; }
/* inline-flex for the same reason .notif-bell is: it takes a 40px touch target
   under pointer:coarse and the glyph has to sit in the middle of it. */
.notif-card-close {
  background: none; border: 0; cursor: pointer; line-height: 1;
  font-size: var(--text-lg); color: var(--color-text-muted); padding: 0 2px;
  display: inline-flex; align-items: center; justify-content: center;
}

/* Header bell. Sized to match .btn-help so the two sit level in .header-user.
   .notif-panel below is absolutely positioned and anchors to .header-user,
   which sets position: relative in base.css for exactly this reason. It
   cannot anchor to .header instead: an absolute element resolves against its
   positioned ancestor's padding box, and .header carries horizontal padding
   (var(--space-8), narrower at <=900px) — anchoring there would land the
   panel in that padding gutter instead of flush with the bell. */
/* inline-flex so the pointer:coarse min-height in responsive.css centres the
   glyph rather than stranding it at the top of a 40px box. At the current size
   those three declarations change nothing. */
.notif-bell {
  position: relative; background: none; border: 0; cursor: pointer;
  font-size: var(--text-lg); line-height: 1; padding: var(--space-1);
  display: inline-flex; align-items: center; justify-content: center;
}
.notif-bell-badge {
  position: absolute; top: -2px; right: -4px;
  background: var(--color-danger); color: var(--color-text-invert);
  border-radius: var(--radius-pill);
  padding: 0 5px; font-size: var(--text-2xs); font-weight: var(--weight-bold);
}

/* The dropdown history. Anchored to the header, scrolls internally so a long
   list never grows the page.

   Border, radius and shadow are .dropdown-panel's (see that rule): the app had
   two floating panels disagreeing on all three. The class is NOT composed onto
   this one — .dropdown-panel carries its own padding and a 240px min-width,
   and both fight the full-bleed rows and their dividers.

   The width is capped against the viewport in responsive.css: this 340px is
   anchored right:0 to .header-user, so on a 320px phone its left edge landed
   at -32px and a third of every row was off-screen. */
.notif-panel {
  position: absolute; top: 100%; right: 0; z-index: var(--z-toast);
  width: 340px; overflow-y: auto;
  /* dvh, not vh, for the reason base.css:30 gives: a phone's browser chrome
     otherwise hides the bottom of the panel. Above ~480px of viewport the
     420px term still wins, so desktop is unchanged. */
  max-height: min(420px, calc(100dvh - var(--header-h) - var(--space-5)));
  background: var(--color-surface);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-dropdown);
}
/* A <button> for the same reason .notif-card-text is. `border: 0` strips the
   native chrome and the border-bottom AFTER it puts the row divider back --
   order matters here, the longhand has to follow the shorthand. `background`
   is left to .is-unread below, which is two classes deep and wins anyway. */
.notif-panel-row {
  padding: var(--space-3) var(--space-4);
  border: 0; border-bottom: 1px solid var(--color-border);
  cursor: pointer;
  display: block; width: 100%;
  appearance: none; -webkit-appearance: none;
  background: none; margin: 0;
  font: inherit; color: inherit; text-align: left;
}
/* Inset, because a row is full-bleed inside .notif-panel: a 2px outset ring
   would be clipped by the panel edge and collide with the next row's divider. */
.notif-panel-row:focus-visible { outline: var(--focus-outline); outline-offset: -2px; }
.notif-panel-row:last-child { border-bottom: 0; }
.notif-panel-row.is-unread { background: var(--color-primary-bg); }

/* Sticky so it survives the panel's own scroll, and on the panel's surface so
   rows do not show through as they pass under it. The empty state below it is
   .empty-state.is-compact — the shared component, already centred, --text-sm
   and --color-text-dim, with a variant for exactly this "inside a smaller box"
   case. The bespoke .notif-panel-empty it replaces is deleted. */
.notif-panel-head {
  position: sticky; top: 0; z-index: 1;
  /* Flex so "Clear all" can sit opposite the label. The head predates that
     control and was plain block; without this the button wraps under the
     title and the sticky head grows a second line. */
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-3);
  background: var(--color-surface);
  border-bottom: 1px solid var(--color-border);
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wider);
  color: var(--color-text-muted);
}
/* "Clear all" in the notification head. Deliberately quiet: it sits above the
   feed it empties, so it must not compete with the rows for attention, and it
   is not an error action. Inherits .btn's sizing from Btn({size:'sm'}); only
   the colour and weight are toned down here. */
.notif-clear-all {
  border: 0; background: none; padding: 0;
  font-weight: var(--weight-normal);
  font-size: var(--text-2xs);
  text-transform: none; letter-spacing: normal;
  color: var(--color-primary);
}
.notif-clear-all:hover { text-decoration: underline; }

/* A third line under the row's title and body. display:block because it is a
   <span> inside a <button> — the row takes phrasing content only — and so it
   stacks rather than running on after the body. */
.notif-row-time {
  display: block;
  margin-top: 2px;
  font-size: var(--text-2xs);
  color: var(--color-text-dim);
}

/* ════ ACCOUNT MENU ════════════════════════════════════════════════════════ */

/* The header's single user control. It replaced three — the display name, a
   platform-admin-only "Change Password" button, and Sign Out — which is why
   the header row stopped overflowing below 900px.

   Anchored to .header-user, which sets position: relative in base.css for
   exactly this. It cannot anchor to .header: that element's horizontal padding
   pushes an absolutely-positioned child into the gutter. Same constraint
   .notif-panel documents. */
.account-trigger {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  padding: 3px var(--space-3);
  background: rgba(255,255,255,.1);
  border: 1px solid rgba(255,255,255,.2);
  border-radius: var(--radius-md);
  color: inherit;
  font: inherit;
  font-size: var(--text-xs);
  cursor: pointer;
}
.account-trigger:hover { background: rgba(255,255,255,.18); }

/* The avatar is a <span> inside a <button> — a <div> there is not phrasing
   content and is invalid inside a button — so it needs its own display. The
   circle itself still comes from .header-user .avatar in base.css. */
.header-user .account-trigger .avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
}
.account-trigger-caret { font-size: 9px; opacity: .8; }

/* Geometry copied from .notif-panel because both hang off .header-user at the
   same offset; surface treatment from .dropdown-panel, which is already shaped
   for padded rows. (.notif-panel re-declares its own precisely because its rows
   are full-bleed and fight that padding — ours are not.) */
.account-menu {
  position: absolute;
  top: 100%;
  right: 0;
  z-index: var(--z-dropdown);
  margin-top: 2px;
  min-width: 220px;
  padding: var(--space-2) 0;
  background: var(--color-surface);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-dropdown);
}
.account-menu-head {
  padding: var(--space-3) var(--space-5) var(--space-4);
  border-bottom: 1px solid var(--color-border-subtle);
  color: var(--color-text);
  font-weight: var(--weight-semibold);
}
.account-menu-login {
  color: var(--color-text-muted);
  font-size: var(--text-xs);
  font-weight: normal;
}
.account-menu-item {
  display: block;
  width: 100%;
  padding: var(--space-3) var(--space-5);
  background: none;
  border: 0;
  text-align: left;
  color: var(--color-text);
  font: inherit;
  font-size: var(--text-sm);
  cursor: pointer;
}
.account-menu-item:hover { background: var(--color-surface-hover); }

/* The photo, wherever it stands in for the initial — header trigger and the
   account page both. object-fit matters: the file is already square, but a
   stale one from before the server-side crop would otherwise stretch. */
.account-avatar-img {
  width: 32px;
  height: 32px;
  border-radius: var(--radius-round);
  object-fit: cover;
}

/* The initial-letter fallback for account-avatar-img, wherever a photo has
   not been set. .avatar's circle is defined by .header-user .avatar in
   base.css, scoped to the header -- outside that ancestor (the account page's
   card, not the header trigger) it paints as unstyled text with no circle.
   This is the same shape, unscoped, added for the account page (2026-08-12,
   pages/045-account.html). */
.account-avatar-init {
  width: 32px;
  height: 32px;
  border-radius: var(--radius-round);
  background: var(--brand-blue-bright);
  color: var(--color-text-invert);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: var(--weight-bold);
  font-size: var(--text-sm);
}

/* The account page shows the SAME two elements as the header does, but as the
   subject of its photo card rather than a menu trigger, where 32px reads as an
   icon. Kept as a modifier because the 32px base above is the header's size
   (shell/001-00-head.html, field/001-head.html) and cannot move. One class, so
   it ties with the base rules on specificity and wins on source order — it
   must stay below them. */
.account-avatar-lg {
  width: 64px;
  height: 64px;
  font-size: var(--text-xl);
}

/* ── Page tabs ─────────────────────────────────────────────────────────────
   A row of buttons that switches panes inside ONE page, as opposed to
   .nav-item, which switches pages. The buttons themselves are ordinary
   .btn — btn-blue for the active one, btn-outline for the rest — so this
   class owns only the row.

   Not .ie-tabs: that is the underline treatment inside the item-edit modal,
   scoped to a panel that is already inside a card. This is the page-level
   shape, first hand-rolled inline on Pricing and now shared with Analytics. */
.tabs {
  display: flex;
  gap: var(--space-2);
  margin-bottom: var(--space-5);
  flex-wrap: wrap;
}

/* ── Item-edit panel tabs ─────────────────────────────────────────────────
   The edit panel used to be one scroll: ad copy, price, policies and store
   placement stacked together, so reaching the one field you opened it for
   meant scrolling past the description editor every time. */
.ie-tabs {
  display: flex;
  gap: 2px;
  border-bottom: 1px solid var(--color-border, #e5e7eb);
  margin-bottom: 16px;
  flex-wrap: wrap;
}
.ie-tab {
  appearance: none;
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  padding: 9px 14px;
  font-size: 13px;
  font-weight: 600;
  color: var(--color-text-muted, #6b7280);
  cursor: pointer;
  /* Keep the label still when it becomes active: the weight is already 600 so
     switching tabs only changes colour and the underline, never the width. */
  white-space: nowrap;
}
.ie-tab:hover { color: var(--color-text-strong, #111827); }
.ie-tab.is-active {
  color: var(--color-primary, #0064d2);
  border-bottom-color: var(--color-primary, #0064d2);
}
.ie-tab:focus-visible {
  outline: 2px solid var(--color-primary, #0064d2);
  outline-offset: -2px;
}

/* ── Sidebar groups ───────────────────────────────────────────────────────
   24 flat entries, nine of them eBay, meant reading the whole list to find
   anything. The items are unchanged; only wrapped. */
.nav-group { margin-bottom: 2px; }
.nav-group-head {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  /* Right padding is one step tighter than the left so the caret's 14px box
     fits without squeezing the label: "Inventory & Vehicles" is the longest
     one and sits within 6px of the space it has. Widen the caret or the gap
     and that label wraps to two lines. */
  padding: 9px var(--space-5) 9px var(--space-7);
  background: none;
  border: none;
  color: inherit;
  font: inherit;
  font-weight: 600;
  font-size: 13px;
  cursor: pointer;
  text-align: left;
  opacity: 0.85;
}
.nav-group-head:hover { opacity: 1; background: var(--color-surface-hover); }
.nav-group-label { flex: 1; }
/* Shape and rotation come from .caret / .caret-tree above; the sidebar only
   says how loud its own caret is. */
.nav-group-head:hover .caret,
.nav-group-head[aria-expanded="true"] .caret { opacity: 1; }
/* Indent so a child reads as belonging to the group above it. */
.nav-group-items .nav-item { padding-left: 34px; }

/* ── The sidebar count badge ──────────────────────────────────────────────
   The little pill on a nav row saying how many things are waiting: unread eBay
   messages, leads still marked new, AI call drafts pending, issue reports with
   an update, the admin issue queue.

   It was hand-written inline five times before it was a class, all five with
   the same six declarations and a raw #dc2626 (or #16a34a for the one that is
   good news rather than a to-do). Adding a sixth is what made it a component.
   The colours are now --color-danger / --color-success, which is a shade darker
   than the hexes were and matches .notif-bell-badge — the bell and the sidebar
   were the same red by intention and had drifted apart by hand.

   VISIBILITY STAYS INLINE, and the JS that fills these sets
   `badge.style.display = n ? '' : 'none'`. That is deliberate and it is why
   there is no `display: none` here: '' REMOVES the inline declaration, so the
   rule below can decide, and 'none' beats any rule. See DESIGN_SYSTEM.md
   rule 3. */
.nav-badge {
  display: inline-block;
  background: var(--color-danger);
  color: var(--color-text-invert);
  border-radius: var(--radius-pill);
  padding: 1px 7px;
  font-size: var(--text-2xs);
  font-weight: var(--weight-bold);
  margin-left: var(--space-2);
}
/* The one badge that is not a to-do: an issue report of yours got an answer. */
.nav-badge-ok { background: var(--color-success); }

/* A count on the GROUP head, for the row that is inside a collapsed group and
   therefore not on screen at all — eBay Messages is nine items down a group
   that starts closed, so its own badge announces nothing until you open the
   group you would only open if you already knew. Hidden once the group IS open,
   because the row's own badge is then visible two lines below and saying it
   twice reads as two different counts.

   This is the rule the inline '' above exists to let through: with the group
   expanded the JS-set '' leaves this `display: none` to win, and the moment the
   count is zero the JS-set 'none' wins in both states. */
.nav-group-head[aria-expanded="true"] .nav-badge { display: none; }

/* ── Web-user permission groups ─────────────────────────────────────────────
   The feature-access box was one flat run of 11 checkboxes with two of them
   indented and no headings, so nothing said which sub-option belonged to which
   area. Grouped under the same headings the sidebar uses, so the dialog and the
   nav describe the app the same way. */
.perm-groups { display: flex; flex-direction: column; gap: 2px; padding: 0; }
.perm-group {
  padding: 10px 12px;
  border-top: 1px solid var(--color-border-subtle);
}
.perm-group:first-child { border-top: 0; }
.perm-group-t {
  font-size: var(--text-2xs);
  font-weight: var(--weight-bold);
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--color-text-dim);
  margin-bottom: 6px;
  cursor: pointer;
  user-select: none;
  display: flex;
  align-items: center;
  gap: 6px;
}
/* Collapsed by default: hide everything under a group header until it's opened,
   so the modal is a short list of headers instead of every checkbox at once. */
.perm-group:not(.perm-open) > :not(.perm-group-t) { display: none !important; }
.perm-group:not(.perm-open) .perm-group-t { margin-bottom: 0; }
.perm-caret { font-size: 10px; transition: transform .15s ease; display: inline-block; }
.perm-group.perm-open .perm-caret { transform: rotate(90deg); }
/* "N on" count on the header — normal case, pushed to the right. */
.perm-count { margin-left: auto; text-transform: none; letter-spacing: 0; font-weight: var(--weight-medium); color: var(--color-text-dim); }
/* A sub-option reads as belonging to the line above it. */
.perm-group .perm-sub { margin-left: 22px; }
/* Admin grants everything above, so it is visually last and set apart. */
.perm-group-admin { background: var(--color-surface-sunken); }
/* The nested block under Vehicle Intake. Stacks its fields and lets every
   control use the full width it has -- a <select> with no width rule collapses
   to its widest option, which is how the store picker came out reading "Use". */
.perm-sub-fields {
  margin-left: 22px;
  padding-left: 12px;
  border-left: 2px solid var(--color-border-subtle);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.perm-sub-fields .field { margin: 0; }
.perm-sub-fields input,
.perm-sub-fields select { width: 100%; box-sizing: border-box; }

/* Hold-to-reveal password eye (login + reset forms). The wrapper is relative so
   the button sits inside the field; the input reserves room on the right so its
   text never runs under the eye. No hex — the eye rides on currentColor/opacity
   so it works in light and dark. */
.pw-wrap { position: relative; display: block; }
.pw-wrap input { padding-right: 40px; }
.pw-eye {
  position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
  background: none; border: 0; padding: 6px; margin: 0; cursor: pointer;
  line-height: 0; font-size: 16px; opacity: .5; color: inherit;
  -webkit-user-select: none; user-select: none; touch-action: none;
}
.pw-eye:hover, .pw-eye:active { opacity: 1; }

/* ── Camera surfaces ──────────────────────────────────────────────────────
   The viewfinder shape shared by the burst camera, the VIN scanner and the
   part tag scanner. All three inline-styled their own copy of this before
   050h-camera-controls.js gave them one set of controls to hang on. */
.cam-stage {
  position: relative;
  /* tokens.css has no pure-black token and --gray-900 (#111827) is its darkest.
     Behind a video frame the difference from #000 is imperceptible, and using
     the token keeps a raw hex out of the stylesheet. */
  background: var(--gray-900);
  /* --radius-2xl is exactly 10px, the cards/tables/stat-tiles step. */
  border-radius: var(--radius-2xl);
  overflow: hidden;
  /* The preview is object-fit: contain, so a tap near the edge can land on a
     letterbox bar. tapToPoint rejects those rather than clamping them. */
  touch-action: none;
}
.cam-stage > video {
  width: 100%;
  display: block;
  max-height: 52vh;
  object-fit: contain;
}
/* Drawn at the tap, and only when a focus was actually requested — the ring is
   a promise that the lens is acting on it. */
.cam-focus-ring {
  position: absolute;
  width: 68px;
  height: 68px;
  margin: -34px 0 0 -34px;
  border: 2px solid var(--white);
  border-radius: 50%;
  pointer-events: none;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .45);
  animation: cam-focus-pulse .4s ease-out;
}
@keyframes cam-focus-pulse {
  from { transform: scale(1.35); opacity: 0; }
  to   { transform: scale(1);    opacity: 1; }
}
.cam-controls {
  display: flex;
  gap: var(--space-4);
  margin-top: var(--space-5);
}
.cam-zoom-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-top: var(--space-3);
}
/* The zoom factor readout. Reserves its own width so the slider does not
   jump sideways as the label goes 1.0x -> 10.0x. Three fragments had this
   as an identical inline min-width before. */
.cam-zoom-val { min-width: 2.5em; }
/* The shutter and the torch. Sized for a gloved hand in a yard — this was
   inline min-height:56px on both buttons before. .btn-shutter is the width
   variant (an id selector doesn't fit — see .btn-blue/.btn-outline/.btn-sm
   above, every other button variant here is a class). */
.cam-controls .btn { min-height: 56px; font-size: 16px; }
.cam-controls .btn-shutter { flex: 2; }
.cam-controls .btn + .btn { flex: 1; }
/* A visible confirmation that the tap registered. Without it, on a still
   subject nothing on screen changes and people tap again, getting doubles. */
.cam-flash {
  position: absolute;
  inset: 0;
  background: var(--white);
  opacity: 0;
  pointer-events: none;
  transition: opacity .18s;
}
.cam-count {
  position: absolute;
  top: 8px;
  right: 10px;
  background: rgba(0, 0, 0, .62);
  color: var(--white);
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 600;
}

/* The full-width action row under a camera preview — the Sharp shot escape
   hatch, which hands the picture to the phone's OWN camera app. Its own class
   rather than another inline flex row: <input capture> is the only working
   answer to a soft photo on iOS, where ImageCapture does not exist, so this
   shape is going to appear under more than one preview. */
.cam-action-row {
  display: flex;
  gap: var(--space-4);
  margin-top: var(--space-4);
}
.cam-action-row .btn {
  flex: 1;
  min-height: 48px;
}
/* The capability line: what this handset actually gave the operator, and the
   raw diagnostic under the dialog. Written by cameraControlsHint and
   cameraCapabilityNote in 050h-camera-controls.js. */
.cam-hint {
  color: var(--color-text-muted);
  font-size: var(--text-xs);
  margin: var(--space-4) 0 0;
}
/* The static explanation for Sharp shot's icon-only button, phone width
   only (responsive.css unhides it). Above 640px the button still carries
   its own label, so this would only repeat it. */
.cam-sharp-hint { display: none; }
/* 760px, wider than the base .modal's 680px — the viewfinder wants the
   room. Was inline max-width:760px on the modal itself; a class because
   responsive.css needs a hook to replace it with a full-height phone sheet
   (rule 1, no inline value to fight with !important). */
.cam-modal { max-width: 760px; }
/* Was inline style="margin-top:12px" — the same 12px .cam-controls already
   reads as --space-5. A class because #bc-thumbs needs a phone-width hook
   too, to become the horizontal strip (responsive.css). */
.cam-thumbs { margin-top: var(--space-5); }
/* The Cancel/Done row. Was an inline flex row; a class for the same reason
   as .cam-modal above — responsive.css needs to fold Cancel away (the
   modal's own ✕ already cancels) and let Done sit in the pinned bar. */
.cam-done-row {
  display: flex;
  justify-content: space-between;
  gap: var(--space-4);
  margin-top: var(--space-7);
}
/* Was inline style="min-width:190px" on #bc-done. */
.cam-done-btn { min-width: 190px; }
/* #bc-diag's disclosure. No `open` attribute in the markup: cameraCapabilityNote
   only ever sets its textContent, never its own or its wrapper's display, so a
   CSS-only show/hide is safe. Forced open here, unconditionally, so it reads
   exactly like the plain paragraph it replaced above 640px — summary hidden,
   diagnostic always shown. responsive.css reverses both below 640px, so it
   collapses behind the summary as a tap target instead of costing vertical
   space in the normal flow. */
.cam-diag { margin: 0; }
.cam-diag > summary { display: none; list-style: none; }
.cam-diag > summary::-webkit-details-marker { display: none; }
.cam-diag > #bc-diag { display: block; }


/* ════ SUPPORT CHAT (js/130-support-chat.js) ═══════════════════════════════
   The floating corner bubble, its slide-up panel and the chat bubbles inside.
   Both bundles carry the widget, so the small-screen shape lives here too:
   below 480px the panel goes full-width, and the bottom offsets add the
   safe-area inset for the field app's home-indicator phones. */
.support-fab {
  position: fixed;
  right: var(--space-6);
  bottom: calc(var(--space-6) + env(safe-area-inset-bottom, 0px));
  z-index: var(--z-dropdown);
  width: 52px; height: 52px;
  border: 0; border-radius: var(--radius-pill);
  background: var(--color-primary); color: var(--color-text-invert);
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
  box-shadow: var(--shadow-dropdown);
  /* Scoped to background, not `all`: the only thing that animates is the hover
     tint. An `all` transition also animated `left` — so dragging the bubble
     (below) lagged, and restoring a saved position slid it in from the corner
     on every load — while `top` jumped, because it is auto here and cannot
     interpolate. Naming the property removes both. */
  transition: background-color var(--transition-fast);
  /* The bubble is draggable (see _scMakeFabDraggable in js/130-support-chat.js,
     which repositions it so a phone user can clear it off content it covers).
     touch-action:none keeps the drag from also panning the page under it. */
  touch-action: none;
}
.support-fab:hover { background: var(--color-primary-hover); }
.support-fab-badge {
  position: absolute; top: -2px; right: -4px;
  background: var(--color-danger); color: var(--color-text-invert);
  border-radius: var(--radius-pill);
  padding: 0 5px; font-size: var(--text-2xs); font-weight: var(--weight-bold);
}
/* The same count chip sitting in a thread row rather than on the bubble. */
.support-fab-badge.is-inline { position: static; margin-left: var(--space-2); }

.support-panel {
  position: fixed;
  right: var(--space-6);
  bottom: calc(var(--space-6) + 60px + env(safe-area-inset-bottom, 0px));
  z-index: var(--z-dropdown);
  width: 340px;
  max-height: min(480px, calc(100dvh - var(--space-9)));
  display: none;
  flex-direction: column;
  background: var(--color-surface);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-dropdown);
  overflow: hidden;
}
.support-panel.open { display: flex; }
.support-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-4) var(--space-5);
  border-bottom: 1px solid var(--color-border);
  flex-shrink: 0;
}
.support-head h3 { font-size: var(--text-base); font-weight: var(--weight-bold); color: var(--color-text-strong); }
.support-head-side { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--color-text-muted); }
.support-online-dot {
  display: inline-block; width: 8px; height: 8px;
  border-radius: var(--radius-pill); background: var(--color-success);
  margin-right: var(--space-1);
}

.chat-log {
  flex: 1; overflow-y: auto;
  padding: var(--space-4) var(--space-5);
  display: flex; flex-direction: column; gap: var(--space-3);
}
.chat-msg { max-width: 85%; align-self: flex-start; }
.chat-msg.is-mine { align-self: flex-end; text-align: right; }
.chat-msg-body {
  display: inline-block; text-align: left;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-xl);
  background: var(--color-surface-sunken);
  font-size: var(--text-sm); color: var(--color-text-body);
  overflow-wrap: anywhere; white-space: pre-wrap;
}
.chat-msg.is-mine .chat-msg-body { background: var(--color-primary-bg-bold); }
.chat-msg-meta { font-size: var(--text-2xs); color: var(--color-text-dim); margin-top: 2px; }

.support-foot {
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--color-border);
  flex-shrink: 0;
}
/* Input() emits .input-inline, whose 200px min-width is sized for forms; in a
   340px panel row it has to yield to the Send button instead. */
.support-input-row { display: flex; gap: var(--space-2); }
.support-input-row .input-inline { flex: 1; min-width: 0; }

.support-thread-row {
  padding: var(--space-2) 0;
  border-bottom: 1px solid var(--color-border-subtle);
}
.support-thread-who { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--color-text-strong); }
.support-thread-peek {
  font-size: var(--text-xs); color: var(--color-text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* The announcement popup reuses .modal-overlay/.modal; these are its body and
   footer paddings, which .modal-header's neighbours normally provide.

   They must also do what .modal-body/.modal-footer do, not just look like
   them. .modal is a flex column capped at 90vh with no overflow of its own,
   so the scrolling belongs to the body: without it a long announcement ran
   past the cap and carried the acknowledge button off-screen with it -- 33
   lines on a 1400x813 desktop, 12 on a phone in landscape. That button is
   the only thing that acks the announcement, and an unacked one re-pops on
   the next poll, so the popup became undismissable rather than merely
   clipped. min-height:0 is required with it: a flex child's min-height is
   auto, i.e. its full content height, so it would refuse to shrink and
   overflow instead of scrolling. */
.support-announce-body {
  padding: var(--space-7) var(--space-9);
  font-size: var(--text-base); color: var(--color-text-body);
  white-space: pre-wrap; overflow-wrap: anywhere;
  overflow-y: auto; min-height: 0;
}
.support-announce-foot {
  padding: 0 var(--space-9) var(--space-8);
  display: flex; justify-content: flex-end;
  flex-shrink: 0;
}

@media (max-width: 480px) {
  .support-panel {
    right: var(--space-3); left: var(--space-3); width: auto;
    max-height: min(70dvh, calc(100dvh - var(--space-9)));
  }
}

/* The field app's bottom tab bar owns that strip of screen (--tabbar-h in
   field.css, plus the home-indicator inset the bar already absorbs), so the
   bubble and its panel sit above it rather than covering the last tab.

   THEY ALSO SWAP SIDES, and that is not cosmetic. The update banner sits at
   bottom: --tabbar-h and stands ~70px tall, so it and the bubble occupy the
   same band of screen; both hugged the right edge, and the bubble's z-index
   (--z-dropdown, 50) is above the banner's 45. The result was the bubble
   painting over "Update now" — the single tap that gets a yard phone off a
   stale build, on the one banner written because a phone sat three weeks on a
   build whose bug was already fixed. The banner keeps its action on the right,
   so the bubble moves left rather than the banner being redesigned around it.
   ONLY THE BUBBLE MOVES. The panel already spans the screen under the
   max-width:480px rule above (left and right both --space-3, width auto), so
   giving it right:auto here would leave a width:auto box anchored on one side
   and it would shrink to its content instead of spanning. It stays where it
   is; on a phone it is full-width anyway, so there is no bubble-to-panel
   mismatch to fix. /app is untouched: it has no tab bar and no update banner,
   hence the .field-app scope. */
.field-app .support-fab { bottom: calc(var(--tabbar-h, 56px) + env(safe-area-inset-bottom, 0px) + var(--space-4)); right: auto; left: var(--space-6); }
.field-app .support-panel { bottom: calc(var(--tabbar-h, 56px) + env(safe-area-inset-bottom, 0px) + var(--space-4) + 60px); }


/* ════ VIN COMPARE (js/133-vin-compare.js) ═════════════════════════════════
   A card per car above the columns, and one card per spec row under them.

   WHY CARDS AND NOT THE TABLE. The page began as one four-column table with a
   Same/Differs pill on every row, and it did its job. What it could not do
   was carry the car: a heading of "A · 2016 GMC Terrain" is a line of text,
   and two donor cars of the same model were two identical lines. The card
   above each column holds what makes a car THAT car — the yard's own photo of
   it when the yard has one, its stock number, its VIN — and the ticked list
   under it is the identity the decode is sure of. The rows below keep the
   table's discipline (one shared label column, both values on one line, so
   the eye never compares the wrong lines) in a shape that can hold a colour
   swatch and a per-row edge.

   THE EDGE IS THE VERDICT, and so is the mark. Green edge + ✓ is the same,
   red edge + ✕ is different, a grey edge is one car only, blue is a note.
   Each state carries a glyph as well as a colour, so it survives greyscale;
   the class name is the third carrier.

   Red for a difference — where the older table used amber. A dismantler
   comparing two cars is asking "will the part off B fit A?", and on that
   question a differing axle ratio is a no, not a caution. The one row that
   is information rather than a verdict ("Decoded from") is blue, and never
   counts.

   .is-single is the same page with one car: one card, one value column, no
   marks. A VIN with nothing to compare against still deserves the sheet. */
.vc-cars {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-6);
  margin-bottom: var(--space-6);
}
.vc-cars.is-single { grid-template-columns: minmax(0, 720px); }
.vc-car {
  display: grid;
  grid-template-columns: 180px 1fr;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-2xl);
  overflow: hidden;
  min-width: 0;
}
/* The photo cell. A car not on the books, or one with no picture yet, shows
   the decoded colour as a large swatch instead — the same fact the row below
   carries, big enough to read from across the desk. */
.vc-car-photo {
  background: var(--color-surface-sunken);
  min-height: 160px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.vc-car-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vc-car-photo .vc-swatch { width: 88px; height: 88px; border-radius: var(--radius-round); }
/* Letter, VIN, stock. The VIN never breaks mid-string — a VIN split over two
   lines is two strings to a reader — so the stock number wraps under it when
   the card is narrow, rather than the VIN giving way. */
.vc-car-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
  padding: var(--space-4) var(--space-6);
  border-bottom: 1px solid var(--color-border);
  background: var(--color-surface-sunken);
  font-size: var(--text-sm);
}
.vc-car-letter {
  font-weight: var(--weight-black);
  color: var(--color-primary-text);
  flex: none;
}
.vc-car-vin {
  font-family: var(--font-mono-ui);
  font-weight: var(--weight-semibold);
  color: var(--color-text-strong);
  letter-spacing: var(--tracking-wide);
  white-space: nowrap;
}
.vc-car-stock { color: var(--color-text-muted); white-space: nowrap; margin-left: auto; }
.vc-facts {
  list-style: none;
  margin: 0;
  padding: var(--space-4) var(--space-6);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  font-size: var(--text-sm);
}
.vc-fact { display: flex; gap: var(--space-3); align-items: baseline; }
.vc-fact::before {
  content: '✓';
  color: var(--color-success);
  font-weight: var(--weight-bold);
  flex: none;
}
.vc-fact-title { font-size: var(--text-md); font-weight: var(--weight-bold); color: var(--color-text-strong); }
.vc-fact-k { color: var(--color-text-muted); }
.vc-fact-v { font-weight: var(--weight-semibold); color: var(--color-text-strong); }

.vc-rows { display: flex; flex-direction: column; gap: var(--space-3); }
.vc-row {
  display: grid;
  grid-template-columns: 200px 1fr 1fr;
  gap: var(--space-4);
  align-items: center;
  padding: var(--space-4) var(--space-6);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-left-width: 4px;
  border-radius: var(--radius-lg);
  font-size: var(--text-sm);
  color: var(--color-text);
}
.vc-rows.is-single .vc-row { grid-template-columns: 200px 1fr; }
.vc-row-same { border-left-color: var(--color-success); color: var(--color-text-muted); }
.vc-row-diff {
  border-left-color: var(--color-danger);
  background: var(--color-surface-sunken);
  font-weight: var(--weight-semibold);
  color: var(--color-text-strong);
}
.vc-row-one  { border-left-color: var(--color-border-strong); }
.vc-row-note { border-left-color: var(--color-primary); }
.vc-row-label { color: var(--color-text-muted); font-weight: var(--weight-normal); }
.vc-row-val {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-width: 0;
  overflow-wrap: anywhere;
}
.vc-mark { flex: none; font-weight: var(--weight-bold); }
.vc-mark-yes   { color: var(--color-success); }
.vc-mark-no    { color: var(--color-danger); }
.vc-mark-muted { color: var(--color-text-dim); }
/* The decoded colour, as itself. Only ever painted from a hex the decode
   returned AND the builder validated; the builder never writes one it made up
   and a car with no hex has no swatch. */
.vc-swatch {
  display: inline-block;
  flex: none;
  width: 14px;
  height: 14px;
  border-radius: var(--radius-xs);
  border: 1px solid var(--color-border-strong);
}
/* Option codes fold: one summary row (shared / unique per side) with the
   per-code rows behind a native disclosure, so forty codes do not bury the
   dozen spec rows that answer most questions. */
.vc-codes { display: contents; }
.vc-codes > summary {
  cursor: pointer;
  list-style: none;
  color: var(--color-primary-text);
  font-weight: var(--weight-semibold);
  font-size: var(--text-xs);
  padding: 0 var(--space-6);
}
.vc-codes > summary::-webkit-details-marker { display: none; }
.vc-codes > summary::before { content: '▸ '; }
.vc-codes[open] > summary::before { content: '▾ '; }

@media (max-width: 760px) {
  .vc-cars, .vc-cars.is-single { grid-template-columns: 1fr; }
  .vc-car { grid-template-columns: 1fr; }
  .vc-car-photo { min-height: 140px; }
  .vc-row, .vc-rows.is-single .vc-row { grid-template-columns: 1fr; gap: var(--space-2); }
}

/* ════ VARIANT WIZARD (js/050l-variant-wizard.js) ══════════════════════════
   One Hollander tree level per screen -- "2.0L or 2.4L", then "VIN C or
   VIN 4" -- instead of a flat list of twenty near-identical labels. Shared by
   the parts wizard, stock-check and the inventory VIN box, so this is the
   only place the shape is styled.

   The resolved list above the open question is a breadcrumb of every level
   already settled, each with its own [change] button -- picking wrong three
   levels up should not mean starting over. .variant-wizard-src only appears
   on a row the VIN actually settled (component logic, not this file, decides
   that); a level the user answered by hand, or one with only a single
   possible answer, gets no badge, because it was not "from the VIN". */
.variant-wizard {
  display: flex;
  flex-direction: column;
  gap: var(--space-6);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-2xl);
  padding: var(--space-6);
}
.variant-wizard-resolved {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding-bottom: var(--space-5);
  border-bottom: 1px solid var(--color-border-subtle);
}
.variant-wizard-done {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-3);
  font-size: var(--text-sm);
}
.variant-wizard-mark { color: var(--color-success); font-weight: var(--weight-semibold); }
/* A picked row's price box and its ✕ (the parts wizard's Picked list), kept
   together: as two separate items of the wrapping row, a phone dropped the ✕
   onto a line of its own under the price. One unit, pushed to the right;
   on a narrow row it moves down whole. */
.variant-wizard-pick-ctl {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  flex: none;
  margin-left: auto;
}
.variant-wizard-src {
  font-size: var(--text-2xs);
  color: var(--color-text-dim);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
}
.variant-wizard-step { display: flex; flex-direction: column; gap: var(--space-4); }
.variant-wizard-head {
  font-size: var(--text-2xs);
  color: var(--color-text-dim);
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
}
.variant-wizard-options { display: flex; flex-direction: column; gap: var(--space-2); }
/* A multi-side level (L. and R. ticked together) offers "I can't tell" as a
   button, because beside checkboxes a radio reads as a third side. The column
   would otherwise stretch it to the full width of the box. */
.variant-wizard-options > .btn { align-self: flex-start; }
