/* ==================================================================
   FONTS, SELF-HOSTED. Two families, six files, 187 KB on disk.

   They used to come from Google: a render-blocking <link> to fonts.googleapis.com whose
   reply then pointed at a SECOND host, fonts.gstatic.com, for the binaries. That is two
   extra DNS lookups and two extra TLS handshakes standing between a cold visitor and the
   first pixel -- paid on exactly the first visit that was being complained about, and
   never on the second, which is why the app felt fine on a reload.

   Same typefaces, same weights, byte-identical files (fetched from gstatic once and
   committed). Now they are same-origin on a connection the HTML already opened, and the
   CSP can say font-src 'self' with no remote origin at all -- which is what ENFORCES the
   decision rather than merely recording it, exactly as img-src does for the logos.

   unicode-range is what keeps this cheap: latin-ext is only fetched when a glyph actually
   needs it, so the usual cost is inter-400-700-latin (48 KB) plus one Plex Mono weight.
   ?v=1 earns the immutable Cache-Control in web.py; bump it if a file is ever replaced.

   font-display:swap matches the &display=swap the Google URL carried, so text paints in a
   fallback immediately and swaps when the face lands. Nothing here is invisible while it
   loads.
   ================================================================== */
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
  src:url(/static/fonts/ibm-plex-mono-500-latin.woff2?v=1) format('woff2');
  unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
  src:url(/static/fonts/ibm-plex-mono-500-latin-ext.woff2?v=1) format('woff2');
  unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
  src:url(/static/fonts/ibm-plex-mono-600-latin.woff2?v=1) format('woff2');
  unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
  src:url(/static/fonts/ibm-plex-mono-600-latin-ext.woff2?v=1) format('woff2');
  unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
@font-face{font-family:'Inter';font-style:normal;font-weight:400 700;font-display:swap;
  src:url(/static/fonts/inter-400-700-latin.woff2?v=1) format('woff2');
  unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@font-face{font-family:'Inter';font-style:normal;font-weight:400 700;font-display:swap;
  src:url(/static/fonts/inter-400-700-latin-ext.woff2?v=1) format('woff2');
  unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}

/* ==================================================================
   TOKENS. Three layers, and the rule that keeps them honest:
   a component may reference layer 2 and layer 3, NEVER layer 1.

   Layer 1  primitives  raw ramps, mode-stable. #1d4ed8 is the same pixel in both themes.
   Layer 2  semantic    what a thing DOES. --accent, --text-secondary, --border-subtle.
   Layer 3  domain      what a thing MEANS here. Visa route, match strength, posting age.

   Layer 3 is the one this app most needed and had none of. Sponsorship route is
   meaningless outside this product, so it gets its own vocabulary rather than borrowing
   --brand-soft and --ok-soft, which chained the taxonomy to the accent and to "success".

   THE COLOUR RULE, 2026-08-31. ONE HUE: blue. Everything else is ink.

   Superseded the 2026-08-10 rule below at the owner's explicit direction after seeing the
   feed: violet for Green Card and green for STEM-OPT are folded into the same blue as
   H-1B, so the product now spends its whole chromatic budget on ONE accent.

   What that costs, recorded so it is a decision and not an accident: route is no longer
   distinguishable at a glance -- a Green Card chip and an H-1B chip are the same colour.
   What carries it instead is the LABEL, which already named the route in full
   (core.SPONSOR_LIKELY_LABELS / VISA_TAG_LABELS): the hue was reinforcing the word, not
   replacing it. So the fact survives; only the glanceability does not.

   The token NAMES stay --visa-gc-* / --visa-stem-* rather than collapsing to one name,
   because ~700 lines of component CSS and scripts/test_contrast.py reference them, and
   because reverting is then a four-value edit here rather than a hunt.

   The rule it replaced, kept for the reasoning:
   Colour means sponsorship. Everything else is ink.

   Actions are ink: black on light, white on dark, no hue at all. Selection is an ink
   border over a neutral fill rather than a coloured wash. That is not minimalism for its
   own sake, it is a budget decision. The accent used to be indigo and the H-1B chip blue,
   neighbours on the wheel, so the button you press and the fact you are scanning for
   competed on every card. Spending nothing on chrome leaves the entire chromatic budget
   for the one thing no other job board can tell this audience: which visa route this
   employer has actually filed for. Six colours in the product, statable in a sentence.

   Everything below the layers is a LEGACY ALIAS. The old names still resolve, so the ~700
   lines of component CSS under here keep working while call sites migrate one at a time.
   ================================================================== */
:root{
  color-scheme:light;

  /* --- Layer 1: primitives ------------------------------------------------
     The neutral ramp carries the whole interface now that chrome has no hue, so it is
     tuned a touch cooler and a touch wider at the dark end: ink needs to sit clearly
     below --gray-900 for a solid button to read as deliberate rather than as text. */
  --gray-0:#ffffff;  --gray-25:#fdfdfe;  --gray-50:#fbfbfc;  --gray-100:#f1f3f6;
  --gray-200:#e4e7ec; --gray-300:#d3d7df; --gray-400:#aeb4c0; --gray-500:#858b99;
  --gray-550:#6d7382; --gray-600:#5f6573; --gray-650:#4e5462; --gray-700:#3c414d;
  --gray-800:#2a2f3a; --gray-900:#101319;
  --gray-950:#0b0d12;
  --indigo-50:#eef1ff; --indigo-100:#e0e4fe; --indigo-300:#a5a8f6;
  --indigo-500:#6366f1; --indigo-600:#4f46e5; --indigo-700:#4338ca;
  --blue-50:#eaf1fe;   --blue-500:#2563eb;   --blue-700:#1d4ed8;
  --violet-50:#f2ecfd; --violet-600:#6d28d9;
  --teal-50:#e2f4f1;   --teal-700:#0f766e;
  --green-50:#e8f7ee;  --green-600:#16a34a;  --green-700:#15803d;
  --amber-50:#fdf6e7;  --amber-200:#f2dfae;  --amber-700:#8a5a08;
  --red-50:#fdeeee;    --red-500:#dc2626;    --red-700:#b3261e;

  /* --- Layer 2: semantic --------------------------------------------------
     --surface2 used to mean BOTH "sunken" and "raised", and it inverted between
     themes (darker than --surface in light, lighter in dark), so one of its two
     uses was always wrong. Split here; call sites migrate in the component pass. */
  --bg-canvas:var(--gray-50); --bg-surface:var(--gray-0); --bg-raised:var(--gray-0);
  --bg-sunken:var(--gray-100); --bg-hover:var(--gray-100); --bg-active:var(--gray-200);
  --text-primary:var(--gray-900); --text-secondary:var(--gray-650);
  --text-tertiary:var(--gray-550); --text-on-accent:var(--gray-0);
  --border-subtle:var(--gray-200); --border-default:var(--gray-300);
  --border-strong:var(--gray-500);
  /* The boundary of a CARD -- a panel, a tile, a stat box. Deliberately two steps stronger
     than --border-subtle, which is a row divider WITHIN one surface and stays where it is:
     repointing that would turn every .adm-t table into a grid. Measured on --bg-canvas,
     --border-subtle is 1.20:1 and the white panel it outlines is 1.03:1 against the page, so
     a panel had no visible edge at all and a column of them read as one undifferentiated
     block. This lands at 2.01:1 -- an edge you can see without the 3.30:1 of --border-strong,
     which is reserved for things you can actually operate. */
  --border-card:var(--gray-400);
  /* --brand became --accent because the accent is a ROLE, not an identity. The old
     name invited "our colour", which is how one token ended up painting the primary
     button, the nav, the tabs, the score ring, the charts AND the focus ring.

     It is now ink, and that is the point: the role is "this is an action", and an action
     does not need a hue to say so. --accent-bg is the SELECTION tint, a neutral, so a
     chosen chip reads as chosen through its border and its weight instead of through a
     wash of colour that would compete with the visa chips beside it. */
  --accent:#1d4ed8; --accent-hover:#1a44bd;
  --accent-bg:#eaf1fe; --accent-bg-hover:#dbe7fd;
  --accent-text:#1a44bd;
  --status-success-text:var(--green-700); --status-success-bg:var(--green-50);
  --status-warn-text:var(--amber-700); --status-warn-bg:var(--amber-50);
  --status-warn-border:var(--amber-200);
  --status-danger-text:var(--red-700); --status-danger-bg:var(--red-50);
  --status-danger-border:var(--red-500);
  --status-info-text:var(--blue-700); --status-info-bg:var(--blue-50);
  --focus-ring:0 0 0 3px rgba(29,78,216,.28);
  /* The tick drawn INSIDE a filled ink control, so its stroke is the on-accent colour and it
     has to be baked into the SVG rather than inherited. Themed here for the same reason
     --select-chevron is: an image cannot read a custom property. */
  --tick-mark:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 13 4 4L19 7'/%3E%3C/svg%3E");
  /* A neutral darkening layer, so a coloured button can have a hover state without every
     route needing its own second shade. Dark mode LIGHTENS instead, because there the
     button already sits near the dark end. */
  --ink-veil:rgba(0,0,0,.16);
  /* A lift for chips and hover states that sit ON a tinted card. */
  --on-card-veil:rgba(255,255,255,.6);
  /* The chevron, once. It is drawn twice in the app for two different reasons: as a MASK
     over currentColor wherever it rides text (the .ic set, disclosures), and as a background
     IMAGE with a baked-in stroke on <select>, which cannot mask. Mode-stable because a mask
     takes its colour from the element, not from the file. */
  --ic-chev:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  --select-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%234e5462' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");

  /* --- Layer 3: domain ----------------------------------------------------
     Three visa families, not five colours: the temporary work routes share a hue
     because they are the same KIND of answer. Green Card and STEM-OPT get their own
     so they stop borrowing the accent and the success colour.

     "No sponsorship" is deliberately NOT red. It is a routine fact this audience
     scrolls past hundreds of times a week, not an error and not something they did
     wrong. Red is reserved for destructive controls only.

     MATCH STRENGTH IS A TRAFFIC LIGHT, 2026-08-11. It was a neutral value ramp, on the
     argument that one continuous variable should not be encoded with three categorical
     colours. Overridden deliberately: match is the second question every card gets asked
     and a percentage is slower to read than a colour, so the arc now says "worth reading"
     before the number is parsed. Green at 70 and up, amber from 40 to 69, red below 40.

     The two objections that made it neutral are answered rather than ignored. Neither
     green nor red is spoken for elsewhere on a card: the accent is ink, "No sponsorship"
     is deliberately not red, and STEM-OPT's green is a chip's text-on-tint, never a
     46px arc. And the ring sits on --match-pill, an OPAQUE surface disc, so it is read
     against a known neutral instead of against whichever route wash the card is wearing
     (a green arc on the STEM-OPT wash was the failure mode). That also makes it
     contrast-testable, which a translucent veil over five different tints would not be.

     --match-track stays neutral. Colouring the unfilled remainder would say the missing
     percentage means something, and it doesn't. */
  --visa-h1b-text:var(--blue-700);   --visa-h1b-bg:var(--blue-50);
  --visa-e3-text:var(--blue-700);    --visa-e3-bg:var(--blue-50);
  --visa-h1b1-text:var(--blue-700);  --visa-h1b1-bg:var(--blue-50);
  --visa-gc-text:var(--blue-700);    --visa-gc-bg:var(--blue-50);
  --visa-stem-text:var(--blue-700);  --visa-stem-bg:var(--blue-50);
  --visa-blocked-text:var(--gray-700); --visa-blocked-bg:var(--gray-100);
  --visa-unknown-text:var(--gray-650); --visa-unknown-bg:var(--gray-100);
  /* ONE HUE, THREE DEPTHS. Was green/amber/red, which put three more colours on every
     single card and was the loudest thing in the feed. A sequential ramp of one hue is the
     standard way to encode MAGNITUDE, which is what a match score is; green-to-red encodes
     good-vs-bad, a judgement the number already makes, printed inside the ring. */
  --match-track:var(--gray-200);  --match-strong:var(--blue-700);
  --match-good:var(--blue-500);   --match-weak:#3b6fd4;   /* 6.70 / 5.17 / 4.76 on white */
  --match-none:var(--gray-300);
  /* Opaque, not a veil: the arc must read the same on all five route washes. */
  --match-pill:var(--bg-surface);
  /* THE VERDICT PANEL. A TINT, not a slab -- #1d4ed8 with white on it was the first cut and it
     read as a dark block bolted to a white card. What the panel has to do is mark a different
     KIND of statement (the score and the sponsorship read are ours; everything on the white is
     the employer's), and a tint does that without shouting.
     Measured on #eaf1fe: the label at #1a44bd is 7.10:1, the sub text at #4e5462 is 6.69:1 and
     the ring at #1d4ed8 is 5.91:1. The match RAMP cannot be used here -- --match-weak lands at
     4.20 and fails -- which is why the ring keeps one colour and the label carries the band. */
  --verdict-bg:#eaf1fe; --verdict-ink:#1a44bd; --verdict-sub:#4e5462; --verdict-line:#c3d7f7;
  /* Keyword marks inside a description. NO NEW HUE, on purpose: the colour rule spends the whole
     chromatic budget on sponsorship, and green is already STEM-OPT's, so a green highlight in a
     description on a plum-routed page would be saying two things at once. Matched is an ink tint
     under a solid rule, to-add is unfilled under a dotted one. mark's UA default is yellow on
     black, so both properties have to be set in both themes or dark mode renders black on grey. */
  --kw-have-bg:var(--status-success-bg);  --kw-have-text:var(--status-success-text);
  --kw-miss-bg:var(--status-danger-bg);   --kw-miss-text:var(--status-danger-text);
  --posting-agency-text:var(--gray-700); --posting-agency-bg:var(--gray-100);

  /* THE ROUTE PALETTE. The whole card is coloured by the best sponsorship route open to
     this reader, not a stripe down its edge. A 3px rule was too quiet to read as meaning
     anything: it looked like trim, so it prompted "why is there a blue line" rather than
     "this employer files H-1Bs".

     Three values per route, and each does a different job:
       -wash  the card fill. Very light on purpose. The card carries a job title, a
              company, four chips and three buttons, and all of that has to stay legible,
              so the tint is a signal you read peripherally rather than a background you
              read through.
       -line  the card border. Where the colour actually asserts itself.
       -ink   the Apply button. Saturated, white text. This is what stops the feed being
              twenty black slabs: the primary action is coloured by what the job offers
              you, so the button, the tint and the chips all say the same thing and there
              is ONE colour system on the card rather than two.

     Everything OUTSIDE a card stays neutral — nav, filter chips, tabs, the top bar — so
     the chrome never competes with the data.

     Two states are deliberately not chromatic. "Blocked" means the posting's own text
     rules sponsorship out, and gets a grey wash with a firmer border: a stated fact, and
     a firmer one than an empty file, which is why it draws heavier than "no record".
     "No record" gets no tint at all, because there is genuinely nothing to say — and it
     is never red. core.py documents absence as "no record, not a refusal", and this
     audience scrolls past it hundreds of times a week.

     Both of those keep the INK Apply button, because a grey one would read as disabled. */
  /* THREE VALUES PER ROUTE, and the middle one is the whole design.
       -fill  the card. Light enough to keep black type on it, saturated enough that you can
              name the colour without being told. This landed after two misses: a near-white
              wash read as "why is this card slightly blue", and a deep navy fill read as
              heavy. The answer was neither end.
       -line  the border, a step up in saturation so the card has a definite edge.
       -ink   the Apply button and the filter swatches, full strength with white type. */
  /* ONE ROUTE COLOUR, 2026-08-31, at the owner's direction after seeing the live grid.

     Every card wears the same blue. The wash no longer says which route an employer has filed
     for, because the CHIP says it and the hue was only ever repeating the word. Four cards side
     by side in four colours read as four categories the eye then has to decode; one colour reads
     as one list, which is what a feed is.

     This finishes the "one hue" pass earlier the same day, which recoloured the CHIP tokens
     below and left these -- so the chips went blue while the cards under them stayed plum,
     green and grey, which is the state the owner was looking at when he asked again.

     Only the h1b trio holds a value; the other four ALIAS it rather than repeating the hex, so
     one hue is structurally true instead of five literals somebody has to keep in sync. That is
     the drift this file has been bitten by before. test_contrast.py follows var() chains, so all
     five pairs stay checked, and reverting is un-aliasing four lines. */
  --route-h1b-fill:#dbe7fb;     --route-h1b-line:#a9c6f4;     --route-h1b-ink:#1d4ed8;
  --route-gc-fill:var(--route-h1b-fill);      --route-gc-line:var(--route-h1b-line);      --route-gc-ink:var(--route-h1b-ink);
  --route-stem-fill:var(--route-h1b-fill);    --route-stem-line:var(--route-h1b-line);    --route-stem-ink:var(--route-h1b-ink);
  --route-blocked-fill:var(--route-h1b-fill); --route-blocked-line:var(--route-h1b-line); --route-blocked-ink:var(--route-h1b-ink);
  --route-none-fill:var(--route-h1b-fill);    --route-none-line:var(--route-h1b-line);    --route-none-ink:var(--route-h1b-ink);
  /* The label on an -ink button. White in BOTH themes, which is why it is its own token rather
     than the on-accent one, which flips to near-black in dark mode because the accent there is
     light ink. The route inks stay saturated in both themes and would render dark text on
     mid-blue. A token rather than a hardcoded #fff so test_contrast.py can see the pair at all;
     five ink backgrounds carried an unchecked literal until now.

     Careful writing comments in here. test_contrast.py's tokens() regex reads every
     declaration it finds inside the :root block, comments included, and its value run reaches
     to the next semicolon. So writing a token name followed by a colon in prose either
     redefines that token as a sentence or swallows the real declarations after it. Both
     happened while this comment was being written. Never write a double-dash name followed by
     a colon in here. */
  --route-on-ink:#fff;

  /* Seniority is one continuous variable, so it gets a neutral VALUE ramp rather than
     three hues. The three classes previously resolved to identical greys, which meant
     the card drew a distinction it never actually showed. */
  --exp-entry-text:var(--gray-600);  --exp-entry-bg:var(--gray-100);
  --exp-mid-text:var(--gray-700);    --exp-mid-bg:var(--gray-200);
  --exp-senior-text:var(--gray-800); --exp-senior-bg:var(--gray-300);

  /* --- Type scale ---------------------------------------------------------
     Nine steps replacing the 22 distinct sizes previously in use, six of which were
     half pixels (9.5, 11.5, 12.5, 13.5, 14.5, 15.5). Half pixels are the signature
     of tuning by eye with no scale to snap to.

     Base moves 15px -> 16px. 15 was a compromise nobody chose, and this app asks
     people to read job descriptions running to 7,000 characters. Dense UI drops to
     14px as a DECISION rather than as a default.

     The display end goes much taller on purpose. h1 was 24px against 15px body, a
     1.6x ratio, and that timidity is most of why these pages read as forms. Contrast
     in scale is what "editorial" actually means, more than whitespace is.

     Weight 800 and the one-off 650 are banned. Inter at 700/28px is emphatic enough.

     TWO FACES, 2026-08-10. Inter keeps the interface; IBM Plex Mono takes every number,
     date, count, percentage and eyebrow label. That is not decoration. This product is
     built on LCA filings, fiscal years, approval counts and deadlines, so numbers are its
     texture, and a face that treats them as data rather than as prose is the honest
     setting for them. Plex also carries the institutional register the subject lives in
     without tipping into pastiche, and its tabular figures stop a column of match
     percentages from shifting as it renders.

     Use --font-data, never --font-mono, for anything the reader is meant to COMPARE.
     --font-mono stays pointed at the same stack and keeps its old meaning, "this is
     literal text": tokens, SQL, pasted résumés. */
  --font-sans:"InterVariable",Inter,-apple-system,"Segoe UI",Roboto,sans-serif;
  --font-mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  --font-data:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  --text-3xs:11px; --leading-3xs:1.40;
  --text-2xs:12px; --leading-2xs:1.45;
  --text-xs:13px;  --leading-xs:1.50;
  --text-sm:14px;  --leading-sm:1.55;
  --text-base:16px;--leading-base:1.60;
  --text-md:18px;  --leading-md:1.45;
  --text-lg:22px;  --leading-lg:1.35;
  --text-xl:28px;  --leading-xl:1.25;
  --text-2xl:36px; --leading-2xl:1.15;
  --weight-regular:400; --weight-medium:500; --weight-semibold:600; --weight-bold:700;
  --tracking-tight:-0.015em; --tracking-normal:0; --tracking-wide:0.06em;

  /* --- Space scale --------------------------------------------------------
     8px base, numbered by hundreds so a value can be inserted later without
     renumbering everything. Replaces 15 distinct gap values; the tuning between
     5px and 9px was never perceptible and is why no two components agreed. */
  --space-0:0; --space-025:2px; --space-050:4px; --space-100:8px; --space-150:12px;
  --space-200:16px; --space-250:20px; --space-300:24px; --space-400:32px;
  --space-500:40px; --space-600:48px; --space-800:64px; --space-1200:96px;

  /* --- Radius, elevation, z, layout --------------------------------------
     The z scale fixes a real ordering bug: .page-progress sat at 200 and
     .modal-overlay at 50, so the progress bar drew OVER an open modal.

     --layout-prose-max is load-bearing. .jd runs the full modal width at 14.5px,
     about 95 characters per line, well past the comfortable reading band. */
  --radius-xs:6px; --radius-sm:10px; --radius-md:14px; --radius-lg:20px; --radius-full:999px;
  --shadow-none:none;
  --shadow-xs:0 1px 2px rgba(20,23,31,.04);
  --shadow-sm:0 1px 3px rgba(20,23,31,.06),0 1px 2px rgba(20,23,31,.04);
  --shadow-md:0 4px 16px rgba(20,23,31,.1),0 1px 3px rgba(20,23,31,.06);
  --shadow-lg:0 20px 60px rgba(15,17,21,.2),0 8px 20px rgba(15,17,21,.08);
  --z-base:0; --z-sticky:100; --z-topbar:200; --z-dropdown:300;
  --z-overlay:400; --z-modal:500; --z-toast:600; --z-progress:700;
  --layout-prose-max:68ch; --layout-form-max:680px; --layout-page-max:1280px;
  --layout-feed-max:1880px;
  --filterbar-h:56px;
  --field-h-sm:32px; --field-h:40px; --field-h-lg:48px;
  --opacity-disabled:.45; --opacity-deemphasis:.62;

  /* --- LEGACY ALIASES -----------------------------------------------------
     Every pre-2026-08-09 token name, still resolving. Delete an entry only once
     grep proves nothing references it. --surface2 keeps its literal value rather
     than aliasing, because its two meanings have to be separated per call site
     and guessing here would silently restyle either the .jd block or the tiles.

     --brand and its family now resolve to ink through --accent. Nothing below had to
     change for that: the aliases were already pointed at semantic tokens rather than at
     literal indigo, which is exactly the property they were introduced for. */
  --bg:var(--bg-canvas); --surface:var(--bg-surface); --surface2:#f3f4f7;
  --ink:var(--text-primary); --muted:var(--text-secondary);
  --line:var(--border-subtle); --line-strong:var(--border-default);
  --brand:var(--accent); --brand-d:var(--accent-hover); --brand-soft:var(--accent-bg);
  --brand-ink:var(--accent-text); --on-brand:var(--text-on-accent);
  --blue:var(--blue-500); --blue-soft:var(--blue-50);
  --danger:var(--status-danger-text); --danger-soft:var(--status-danger-bg);
  --ok:var(--status-success-text); --ok-soft:var(--status-success-bg);
  --warn:var(--status-warn-text); --warn-bg:var(--status-warn-bg);
  --warn-line:var(--status-warn-border);
  --r-sm:var(--radius-sm); --r:var(--radius-md); --r-lg:var(--radius-lg);
  --e1:var(--shadow-sm); --e2:var(--shadow-md); --e3:var(--shadow-lg);
  --focus:var(--focus-ring);
  /* MOTION. One vocabulary for the whole app so nothing feels hand-timed. Measured off a
     reference feed we liked: ~0.15s for hover/colour, ~0.2s for anything that moves, and a
     slower custom ease for collapse/expand so a card folding away reads as deliberate rather
     than as a glitch. These are the only durations anything should use. */
  --ease:cubic-bezier(.4,0,.2,1);
  --ease-out:cubic-bezier(0,0,.2,1);
  --ease-collapse:cubic-bezier(.61,.1,.21,.92);
  --t-fast:.15s; --t:.2s; --t-slow:.36s;
  /* Sticky offset for the feed's filter rail. base.html measures the real .topbar height onto
     :root on load and on resize; this is the no-JS fallback. Layout constant, not a theme token,
     so it is deliberately NOT redeclared in [data-theme="dark"]. */
  --topbar-h:65px;
  /* THE LOGO PLATE. Mode-stable, like --topbar-h above and for the same kind of
     reason: this is a property of the ASSETS, not of the interface. A harvested brand
     logo is drawn for paper -- most are solid black on transparent, and
     "Apple logo black.svg" is literally black -- so the surface it sits on is a
     constant of the artwork. Declared here and deliberately NOT redeclared in
     [data-theme="dark"]. There is no filter:invert in this stylesheet and this is not
     the place to introduce one: inverting wrecks every logo that already has colour.

     Built from layer 1 rather than a literal so the mode-stability is STRUCTURAL.
     Layer 1 is never redeclared in dark, which is exactly the property this needs.

     THE DARK-MODE EDGE. On #161822 the plate is a 15.9:1 step, i.e. the brightest
     object on the tile, and that is correct -- it is paper. Two things keep it from
     glaring, and neither is dimming it, because any plate light enough to carry solid
     black artwork is a large step from that canvas. First the plate HUGS its artwork:
     36x36 for a monogram is ~2% of the card, a mark rather than a light leak, where a
     full-bleed bar would be. Second the rim is INSET and DARKER than the plate --
     1.30:1 against the plate, 12.0:1 against the card -- so the boundary is a graded
     step instead of a hard bright-on-dark cut, which is what actually rings.

     -ink is the monogram's two letters. It CANNOT resolve to --text-primary, because the plate
     does not invert, and on dark --text-primary is #ebedf3, which would be near-white
     letters on a near-white plate. 9.20:1 in BOTH themes because both sides are
     mode-stable. NB: no comment in :root may write a token name immediately followed by
     a colon -- test_contrast.py::tokens regexes this whole body, so it reads that as a
     DECLARATION and silently overwrites the real one with prose. */
  /* THE PLATE HAS TO SIT IN THE CARD, NOT ON IT. --gray-100 against --bg-surface with a
     --gray-300 ring drew a visible grey rectangle around every mark, which reads as a sticker
     stuck to the card rather than as part of it. The plate is now one step off the card and the
     ring is a hairline at 6% ink -- enough to hold a white-cornered logo, not enough to outline
     the tile. Dark mode overrides both; see the note there. */
  --logo-plate:#f6f7f9;
  --logo-plate-edge:rgba(17,19,28,.06);
  --logo-plate-ink:var(--gray-700);
}
/* Dark overrides layer 2 and layer 3 ONLY, never layer 1. The old block redefined what
   were effectively primitives, which is how --brand-d came to mean "darker" in light and
   "lighter" in dark. Every legacy alias above points at a semantic token, so they all
   follow from here without being restated.

   Two model fixes. Surfaces now get LIGHTER as they approach the viewer, so --bg-raised
   sits above --bg-surface instead of --surface2 inverting its meaning between themes.
   And shadows get WEAKER, not stronger: the old rgba(0,0,0,.6) was invisible against a
   #0f1115 canvas, which is why nothing read as elevated in dark mode. */
[data-theme="dark"]{
  color-scheme:dark;
  /* THE PLATE STAYS LIGHT HERE ON PURPOSE and that is not a bug: most brand marks are drawn in
     dark ink for white paper, so on a #161822 card they would disappear. But --gray-100 is
     #f1f3f6, and a solid near-white rectangle on a dark card is a glaring sticker -- which is
     exactly the complaint. So it becomes TRANSLUCENT: the card shows through, the plate reads
     as a soft lift rather than a cut-out, and a dark logo still has something to stand on.

     OPAQUE, not rgba(), and that is deliberate: scripts/test_contrast.py can only resolve
     literal colours, and an alpha value made the --logo-plate-ink pair UNRESOLVED -- the
     monogram's dark ink would have gone unchecked in the theme where it is most at risk. This
     is the exact composite of rgba(233,237,244,.82) over --bg-surface, so it looks identical
     on a card and stays measurable. */
  --logo-plate:#c3c7ce;
  /* DARKER THAN THE PLATE, same as in light. A white rim on a light plate is invisible and
     does nothing for a logo with white corners. */
  --logo-plate-edge:rgba(17,19,28,.12);
  --bg-canvas:#0c0e14; --bg-sunken:#10121a; --bg-surface:#161822; --bg-raised:#1e212e;
  --bg-hover:#232738; --bg-active:#2c3045;
  --surface2:#1c1f2b;
  --text-primary:#ebedf3; --text-secondary:#9ea5b8; --text-tertiary:#7d849a;
  /* Ink inverts. On dark, an action is the lightest thing on screen and its label is the
     canvas, which is the same statement the light theme makes with the values swapped. */
  /* Deep enough to carry white text at 13px: a lighter blue reads better as a LINK on this
     canvas but fails 4.5:1 the moment it becomes a filled button, and the same token does
     both jobs. --accent-text is the light one, for text ON the canvas. */
  --accent:#2563eb; --accent-hover:#3b76ef; --accent-bg:#16233f;
  --accent-bg-hover:#1d2f52; --accent-text:#a8c5fb;
  --text-on-accent:#ffffff;
  /* --border-strong is a CONTROL boundary, so it owes 3:1 against every surface it can sit
     on, not just against the canvas. At #5c6280 it cleared --bg-canvas and missed
     --bg-surface at 2.96:1, which is exactly the input border on every panel. */
  --border-subtle:#232738; --border-default:#333851; --border-strong:#666c88;
  /* Same job, same weight: 2.08:1 against --bg-surface here, 2.08:1 in light. The two themes
     are matched on RATIO rather than on a shared grey step, because the surfaces they sit on
     are not the same distance from their canvas. */
  --border-card:#454b6b;
  --status-success-text:#6ee7a0; --status-success-bg:#0f2d1e;
  --status-warn-text:#f0c05a; --status-warn-bg:#2a2212; --status-warn-border:#4a3c1a;
  --status-danger-text:#f5a3a3; --status-danger-bg:#3a1a1a; --status-danger-border:#f5a3a3;
  --status-info-text:#93c5fd; --status-info-bg:#152340;
  --focus-ring:0 0 0 3px rgba(77,130,243,.35);
  --tick-mark:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 13 4 4L19 7'/%3E%3C/svg%3E");
  --ink-veil:rgba(255,255,255,.14);
  --on-card-veil:rgba(255,255,255,.07);
  --select-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ea5b8' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  --visa-h1b-text:#93c5fd;  --visa-h1b-bg:#152340;
  --visa-e3-text:#93c5fd;   --visa-e3-bg:#152340;
  --visa-h1b1-text:#93c5fd; --visa-h1b1-bg:#152340;
  --visa-gc-text:#93c5fd;   --visa-gc-bg:#152340;
  --visa-stem-text:#93c5fd; --visa-stem-bg:#152340;
  --visa-blocked-text:#9ea5b8; --visa-blocked-bg:#232738;
  --visa-unknown-text:#9ea5b8; --visa-unknown-bg:#232738;
  /* The traffic light, lifted for a dark surface. Same three values the status tokens use,
     which are already carried at this weight elsewhere in the theme. */
  --match-track:#2c3045; --match-strong:#93c5fd; --match-good:#6f9de8;
  /* #18253f is --route-h1b-fill's dark value: the tint the card used to wear, now worn by the
     panel alone. Against it, #bfdbfe is 10.0:1 and #c9d9f5 is 10.7:1. */
  --verdict-bg:#18253f; --verdict-ink:#bfdbfe; --verdict-sub:#c9d9f5; --verdict-line:#2d4a7f;
  --match-weak:#5f8fd8;  --match-none:#454d5d;   /* 9.80 / 6.45 / 5.39 on #161822 */
  --match-pill:var(--bg-surface);
  --kw-have-bg:var(--status-success-bg);  --kw-have-text:var(--status-success-text);
  --kw-miss-bg:var(--status-danger-bg);   --kw-miss-text:var(--status-danger-text);
  --posting-agency-text:#9ea5b8; --posting-agency-bg:#232738;
  /* On a #0c0e14 canvas a tint has to be a LIFT rather than a wash, so each route's fill
     is its hue mixed up out of the surface rather than down out of white. The inks stay
     saturated enough to carry white text at 14px. */
  /* Dark mode inverts the method rather than the values: the fill is the hue mixed UP out of
     the canvas instead of down out of white, so a card still sits above the page and the type
     on it is still the theme's own light text. */
  /* ONE TRIO HERE TOO, and the other four are deliberately ABSENT rather than restated.

     They alias the h1b trio up in :root, and a custom property is substituted on the element
     that uses it -- so under this theme var(--route-gc-fill) walks to the h1b line below and
     lands on the dark value. Restating them here would be four more literals free to drift from
     these three, which is the whole reason they became aliases. test_contrast.py resolves them
     the same way: its dark table starts as a copy of light and only overrides what this block
     declares.

     The ink is a touch darker than it looks like it should be, and that is measured: white on
     the value it used to carry (#3b76ef) came to 4.18:1, under the 4.5 this theme enforces
     everywhere else, so the Apply label on a dark-mode card had been failing since the route
     palette shipped. Nobody noticed because the pair was unreachable by test_contrast.py until
     --route-on-ink replaced a hardcoded white. This is the nearest darker value that clears 4.5
     while staying above 3:1 against its own card fill. */
  --route-h1b-fill:#18253f;     --route-h1b-line:#2d4a7f;     --route-h1b-ink:#3870e3;
  /* Still white. Restated rather than inherited so the value is visible next to the inks it
     has to sit on, which is where anyone darkening one of them will be looking. */
  --route-on-ink:#fff;
  --exp-entry-text:#8a90a2;  --exp-entry-bg:#1c2030;
  --exp-mid-text:#a8afc2;    --exp-mid-bg:#232738;
  --exp-senior-text:#c5cbdb; --exp-senior-bg:#2c3045;
  --blue:#60a5fa; --blue-soft:#152340;
  --shadow-xs:none;
  --shadow-sm:none;
  --shadow-md:0 4px 20px rgba(0,0,0,.45);
  --shadow-lg:0 24px 64px rgba(0,0,0,.6);
}
*{box-sizing:border-box}
html,body{margin:0}
/* Skip link. Positioned off screen rather than hidden, because display:none and
   visibility:hidden both drop it out of the tab order, which is the one thing it exists to
   be in. It appears where focus does, on top of the sticky top bar. */
.skiplink{position:fixed;top:var(--space-100);left:var(--space-100);z-index:calc(var(--z-progress) + 1);
  transform:translateY(-200%);
  padding:var(--space-100) var(--space-200);border-radius:var(--radius-sm);
  background:var(--accent);color:var(--text-on-accent);
  font-size:var(--text-sm);font-weight:var(--weight-semibold);text-decoration:none;
  transition:transform var(--t-fast) var(--ease)}
.skiplink:focus{transform:none;color:var(--text-on-accent)}
/* main takes focus from the skip link, so it must not draw a ring for a click. */
main:focus{outline:none}

/* ------------------------------------------------------------------
   MOTION LAYER
   Everything interactive settles instead of snapping. Deliberately lists the
   properties rather than using `all`: `all` would also animate width/height/top on
   every hover and force layout on each frame, which is what makes "smooth" CSS feel
   sluggish. Colour and opacity are composited cheaply; transform is GPU-cheap too.
   ------------------------------------------------------------------ */
a,button,.btn,.iconbtn,.ico,.chip,.segb,.ck,.tag,.pill,.list-row,.card,
input,select,textarea,summary,.vt,.h1b,.cx,.ev,.intl,.spon,.nospon,.agency{
  transition:color var(--t-fast) var(--ease),
             background-color var(--t-fast) var(--ease),
             border-color var(--t-fast) var(--ease),
             box-shadow var(--t) var(--ease),
             opacity var(--t) var(--ease),
             transform var(--t) var(--ease);
}
/* Pressed states: a small, instant-feeling scale that makes a click land physically. */
button:active,.btn:active,.ico:active,.segb:active{transform:scale(.97)}
.card:active{transform:scale(.995)}

/* Honour the OS setting. The app previously only silenced skeleton shimmer, so adding
   motion everywhere without this would be a real accessibility regression: reduced-motion
   is set by people who get migraines or motion sickness from UI movement. */
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{
    animation-duration:.001ms !important; animation-iteration-count:1 !important;
    transition-duration:.001ms !important; scroll-behavior:auto !important;
  }
}
body{font-family:var(--font-sans);background:var(--bg-canvas);color:var(--text-primary);font-size:var(--text-base);line-height:var(--leading-base);-webkit-font-smoothing:antialiased}
a{color:var(--brand);text-decoration:none;touch-action:manipulation}
main{padding:24px 26px 72px;padding-left:max(26px,env(safe-area-inset-left));padding-right:max(26px,env(safe-area-inset-right))}
h1{font-size:var(--text-xl);margin:0;letter-spacing:-.015em;font-weight:700;text-wrap:balance}
h2{font-size:var(--text-md);margin:0;font-weight:600}
h3{font-size:var(--text-base);margin:0;font-weight:600}
.sub{color:var(--muted);margin:5px 0 20px;font-size:var(--text-sm)}
.muted{color:var(--muted);font-size:var(--text-xs)}
.small{font-size:var(--text-xs)}
/* content links readable, not loud */
.sub a,p a,li a,.note a,.banner a,.empty a,.flash a,.muted a,.cmeta a,.help a{color:var(--brand);font-weight:500;text-decoration:underline;text-underline-offset:2px;text-decoration-thickness:1px;text-decoration-color:color-mix(in srgb,var(--brand) 40%,transparent)}
.sub a:hover,p a:hover,li a:hover,.note a:hover,.banner a:hover,.empty a:hover,.flash a:hover,.muted a:hover,.help a:hover{text-decoration-color:var(--brand)}
.btn,a.btn,.jobpick a,.careers li a{text-decoration:none}

/* ---- ICONS ----------------------------------------------------------------
   One base class, one custom property. Every icon is a CSS mask painted with
   currentColor, so it inherits the text colour, both themes, and every hover,
   focus and disabled state for free, with no extra request and nothing to
   go stale next to the label it sits beside.

   Sized in `em` on purpose: an icon set in a 13px chip and the same icon in a
   16px button should not need two rules. Always pair with aria-hidden="true"
   when the neighbouring text already says the thing. */
.ic{display:inline-block;width:1em;height:1em;flex:0 0 auto;background:currentColor;
  -webkit-mask:var(--ic) center/contain no-repeat;mask:var(--ic) center/contain no-repeat}
.ic-chevron{--ic:var(--ic-chev)}
.ic-external{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'/%3E%3C/svg%3E")}
.ic-arrow-left{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E")}
.ic-arrow-right{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14M13 6l6 6-6 6'/%3E%3C/svg%3E")}
.ic-upload{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12'/%3E%3C/svg%3E")}
.ic-alert{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 8v5M12 16.5v.01'/%3E%3C/svg%3E")}
.ic-check{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 13 4 4L19 7'/%3E%3C/svg%3E")}
/* The eye is struck through rather than closed when the password is visible. A closed
   lid and an open lid differ by a few pixels of curve at 17px and read as the same
   glyph; a slash is unambiguous at any size, and it is the convention every password
   manager already taught people. */
.ic-eye{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E")}
.ic-eye-off{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.6 5.1A10.9 10.9 0 0 1 12 5c6.5 0 10 7 10 7a18.6 18.6 0 0 1-2.4 3.5M6.2 6.2A18.3 18.3 0 0 0 2 12s3.5 7 10 7a10.7 10.7 0 0 0 5.2-1.3'/%3E%3Cpath d='M9.9 9.9a3 3 0 0 0 4.2 4.2'/%3E%3Cpath d='m2 2 20 20'/%3E%3C/svg%3E")}
/* The external mark trails a link's text, so it needs its own air. Inside a flex button the
   parent's gap handles it; inside an inline run, which is where most of these live, there is
   no gap to inherit and the glyph ended up welded to the last letter. */
a .ic-external{margin-left:.3em;width:.85em;height:.85em;vertical-align:baseline;opacity:.6}
a:hover .ic-external{opacity:1}

/* ---- DISCLOSURES ----------------------------------------------------------
   Every <details> in the app, styled once. The native marker is a filled triangle that
   matches nothing else here and renders at a different size and optical weight in each
   engine, which is why these read as unfinished next to the rest of the chrome. Replaced
   with the same chevron the selects and filter chips use, rotated to point at where the
   content will appear.

   Applied to the element rather than to a class so a disclosure added later cannot forget
   to opt in — this is exactly the kind of detail that drifts. */
details > summary{display:flex;align-items:center;gap:var(--space-100);cursor:pointer;
  list-style:none;width:fit-content;max-width:100%;
  padding:var(--space-050) 0;touch-action:manipulation}
details > summary::-webkit-details-marker{display:none}
details > summary::before{content:"";width:12px;height:12px;flex:0 0 auto;
  background:currentColor;
  -webkit-mask:var(--ic-chev) center/contain no-repeat;
  mask:var(--ic-chev) center/contain no-repeat;
  transform:rotate(-90deg);transition:transform var(--t-fast) var(--ease)}
details[open] > summary::before{transform:none}
details > summary:focus-visible{outline:none;box-shadow:var(--focus-ring);border-radius:var(--radius-xs)}
/* The password-reset toggle sits inline in a table row and supplies its own layout. */
.pwreset > summary{display:inline-flex}

/* top bar */
/* No backdrop blur. It was blurring an opaque canvas, so it bought nothing visible and
   cost a compositor layer on the one element that is painted on every scroll frame. */
.topbar{display:flex;align-items:center;gap:var(--space-250);background:var(--surface);border-bottom:1px solid var(--line);padding:14px 24px;flex-wrap:wrap;position:sticky;top:0;z-index:var(--z-topbar);padding-left:max(24px,env(safe-area-inset-left));padding-right:max(24px,env(safe-area-inset-right))}
.brand{display:flex;align-items:center;gap:var(--space-100);font-size:var(--text-md);font-weight:700;color:var(--ink);letter-spacing:-.02em}
/* The wordmark is ink like everything else. A gradient here was the app's loudest claim
   to a brand colour, and this product's identity is the data, not a hue. */
.brand b{color:var(--accent);font-weight:700}
.topbar nav{display:flex;gap:var(--space-050);flex:1;flex-wrap:wrap}
.topbar nav a{color:var(--muted);font-weight:500;font-size:var(--text-sm);padding:8px 14px;border-radius:var(--radius-full);transition:background var(--t-fast) var(--ease),color var(--t-fast) var(--ease)}
.topbar nav a:hover{color:var(--ink);background:var(--surface2)}
.topbar nav a.active{color:var(--ink);background:var(--bg-active);font-weight:600}
.acct{display:flex;align-items:center;gap:var(--space-150)}
.who{color:var(--muted);font-size:var(--text-xs);font-weight:500}

/* buttons — minimal
   touch-action:manipulation on everything tappable removes the 300ms wait a mobile
   browser spends deciding whether a tap was the first half of a double-tap zoom. */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--space-100);border:1px solid var(--line-strong);background:var(--surface);color:var(--ink);padding:9px 16px;border-radius:var(--r);font:inherit;font-size:var(--text-sm);font-weight:600;cursor:pointer;touch-action:manipulation;transition:background var(--t-fast) var(--ease),border-color var(--t-fast) var(--ease),color var(--t-fast) var(--ease),box-shadow var(--t-fast) var(--ease),opacity var(--t-fast) var(--ease),transform var(--t-fast) var(--ease);white-space:nowrap;text-decoration:none}
.btn:hover{border-color:var(--border-strong);color:var(--ink);background:var(--bg-hover)}
.btn:focus-visible{outline:none;box-shadow:var(--focus)}
/* Solid ink. An Apply button is the one action on a card that matters, and ink says that
   more confidently than a tinted one did while spending nothing from the colour budget. */
.btn.primary{background:var(--accent);color:var(--text-on-accent);border-color:var(--accent)}
.btn.primary:hover{background:var(--accent-hover);border-color:var(--accent-hover);color:var(--text-on-accent)}
.btn.ghost{background:transparent;border-color:transparent;color:var(--muted)}
.btn.ghost:hover{background:var(--surface2);border-color:transparent;color:var(--ink)}
.btn.tertiary{background:transparent;border-color:transparent;color:var(--ink);padding-left:6px;padding-right:6px;text-decoration:underline;text-underline-offset:3px;text-decoration-color:var(--line-strong)}
.btn.tertiary:hover{background:var(--surface2);text-decoration-color:var(--ink)}
.btn.danger{color:var(--danger);border-color:transparent;background:transparent}
.btn.danger:hover{background:var(--danger-soft);border-color:transparent;color:var(--danger)}
.btn.sm{padding:6px 11px;font-size:var(--text-xs);border-radius:var(--radius-sm)}
.btn.lg{padding:10px 18px;font-size:var(--text-sm)}
.btn:disabled{opacity:.5;cursor:default;box-shadow:none}
.btn .ic{width:1.05em;height:1.05em;opacity:.7}
.btn.primary .ic{opacity:.85}
.iconbtn{border:1px solid var(--line-strong);background:var(--surface);border-radius:var(--radius-sm);padding:7px 12px;cursor:pointer;font-size:var(--text-xs);font-weight:600;line-height:1;color:var(--ink);touch-action:manipulation}
.iconbtn:hover{border-color:var(--border-strong);background:var(--bg-hover)}
.iconbtn:focus-visible{outline:none;box-shadow:var(--focus)}
.inline{display:inline;margin:0}

/* flash messages
   One neutral style used to serve all ~80 flash() call sites, success and failure alike, so
   "Résumé saved" and "Delete failed partway" were visually identical. Severity now comes
   through as a Flask category and lands on these modifiers. Uncategorised stays neutral, which
   is what every un-migrated call site still gets.

   Not a "colour means sponsorship" exception: that rule is about the FEED's semantic colour.
   These reuse the existing --danger/--ok status tokens, which test_contrast.py already gates. */
.flashes{margin-bottom:20px}
.flash{background:var(--brand-soft);border:1px solid var(--line);color:var(--ink);padding:11px 15px;border-radius:var(--r);margin-bottom:8px;font-size:var(--text-sm)}
.flash:last-child{margin-bottom:0}
.flash.is-error{background:var(--danger-soft);border-color:var(--danger);color:var(--danger)}
.flash.is-ok{background:var(--ok-soft);border-color:var(--ok);color:var(--ok)}

/* layout primitives */
.panel{background:var(--surface);border:1px solid var(--border-card);border-radius:var(--r-lg);padding:22px 24px;margin:0 0 18px}
.section-head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-150);margin:0 0 16px;padding-bottom:14px;border-bottom:1px solid var(--line)}
.section-head h2{margin:0}
.count-pill{display:inline-block;background:var(--surface2);border:1px solid var(--line);border-radius:var(--radius-full);padding:1px 9px;font-size:var(--text-2xs);font-weight:600;color:var(--muted);margin-left:8px;vertical-align:middle}
.list-row{display:flex;align-items:center;justify-content:space-between;gap:var(--space-150);padding:13px 15px;border:1px solid var(--border-card);border-radius:var(--r);background:var(--surface);margin:8px 0;transition:border-color .15s,background .15s}
.list-row:hover{border-color:var(--line-strong);background:var(--surface2)}
.row-main{flex:1;min-width:0}
.row-actions{display:flex;gap:var(--space-100);align-items:center;flex-shrink:0}
.cta-banner{display:flex;align-items:center;gap:var(--space-200);justify-content:space-between;flex-wrap:wrap;background:var(--brand-soft);border:1px solid var(--border-card);border-radius:var(--r-lg);padding:18px 20px;margin:0 0 20px}
.cta-banner .cta-text{flex:1;min-width:240px}
.cta-banner h2{margin:0 0 4px;color:var(--ink);font-size:var(--text-md)}
.cta-banner p{margin:0;color:var(--muted);font-size:var(--text-sm);line-height:1.55}
.addbox{display:none;border:1px solid var(--border-card);border-radius:var(--r);padding:16px 18px;margin:12px 0 6px;background:var(--surface2)}
.addbox.open{display:block;animation:fade .16s ease}
@keyframes fade{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}

/* the change-password form on /profile. Narrow on purpose: three short fields in a column read
   as a sequence, and a full-width password input invites the eye to look for more content. */
.pwform{max-width:340px;margin-top:var(--space-200)}
.pwform .field{margin-bottom:var(--space-200)}
.pwform label{display:block;margin-bottom:4px;font-size:var(--text-xs);font-weight:600}
.pwform input[type=password]{width:100%}
.pwform .dim{margin:4px 0 0;font-size:var(--text-2xs)}

/* The match control while a search is active. web.py deliberately bypasses the match floor when
   the search box has text -- typing a company name should find it whatever it scores -- and the
   defect was that the slider went on claiming "MATCH 50%+" over a grid of sub-30% cards. Greyed
   rather than disabled: the value is still yours and comes back the moment you clear the box, so
   the control must stay operable and only stop asserting something untrue. */
.matchfilter.stood-down{opacity:.55}
.matchfilter.stood-down .rngval{font-style:italic;font-weight:500}

/* error pages (404 / 500)
   Same chrome, tokens and theme as everything else -- the whole point is that a mistyped URL
   does not drop you out of the app. The code is set large but in --muted: it is a label, not
   the message, and the sentence under the heading is what the reader actually needs. */
.errpage{max-width:520px;margin:12vh auto;text-align:center}
.errcode{font-size:var(--text-xl);font-weight:700;color:var(--muted);letter-spacing:.08em;margin:0 0 4px}
.errpage h1{margin:0 0 10px}
.errpage .sub{color:var(--muted);line-height:1.6;margin:0 0 var(--space-300)}
.erracts{display:flex;gap:var(--space-150);justify-content:center;flex-wrap:wrap}

/* login
   The card keeps a hairline instead of a drop shadow. A shadow on the only element on an
   otherwise empty page has nothing to be elevated ABOVE, so it read as a default rather
   than a decision. The form itself is left-aligned inside a centred card: labels and inputs
   are a sequence to work through, and centring them makes each one start in a new place. */
.login{max-width:400px;margin:13vh auto;text-align:center}
.login .brand{justify-content:center;font-size:var(--text-xl);margin-bottom:8px}
.login .sub{margin-bottom:var(--space-300)}
.login form{display:flex;flex-direction:column;gap:var(--space-200);margin-top:0;text-align:left;background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);padding:28px 26px;box-shadow:var(--shadow-none)}
.login .field{margin-bottom:0}
.login .field-label{font-weight:var(--weight-medium);color:var(--text-primary);font-size:var(--text-xs)}
.login .btn{width:100%;padding-top:11px;padding-bottom:11px;margin-top:var(--space-050)}
input,textarea,select{font:inherit;padding:11px 14px;border:1px solid var(--line-strong);border-radius:var(--r);width:100%;background:var(--surface);color:var(--ink);transition:border-color .15s,box-shadow .15s}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--brand);box-shadow:var(--focus)}
/* The native OS select arrow was the loudest "this is unstyled" signal in the product.
   appearance:none removes it; the chevron comes back as a background image whose colour is
   themed through --select-chevron. padding-right leaves room so a long option never runs
   under it. Only real dropdowns: [multiple] and [size] render as list boxes, not menus. */
select:not([multiple]):not([size]){appearance:none;-webkit-appearance:none;
  background-image:var(--select-chevron);background-repeat:no-repeat;
  background-position:right var(--space-150) center;padding-right:var(--space-400)}
select::-ms-expand{display:none}
::placeholder{color:var(--muted);opacity:.7}
textarea{resize:vertical}

/* feed controls */
.feedrow{display:flex;align-items:center;justify-content:space-between;gap:var(--space-150);flex-wrap:wrap}
.feedtools{display:flex;gap:var(--space-100)}
/* FILTER RAIL. Twelve controls in a horizontal toolbar read as a settings screen sitting on top of
   the feed, so eight of them used to fold away behind a disclosure. A left column gives them room
   to stay open instead. The information architecture is unchanged: the controls that START a
   search (text, career track — plus sort and the tabs, over in .results) stay visible at every
   width, and only the eight that NARROW one live under "Filters".
   Desktop visibility is pure CSS. The old #fpanel shipped with the `hidden` ATTRIBUTE and only
   setPanel() cleared it, so a returning user whose jm_fpanel was "0" could get a filter panel that
   never appeared, with no JS error. Nothing here depends on JS having run. */
/* The cap is deliberately way above a laptop's width. .feedwrap is fluid at viewport-52
   (main's padding), so this only binds above a 1612px viewport — below that it's a no-op, and
   above it the extra width goes to the card grid instead of to gutters. */
.feedwrap{max-width:1880px;margin:0 auto}

/* ---- company page ---- */
/* The header mirrors a card's logo + meta stack, so the page reads as "one card, expanded". */
/* margin-right, not just the vertical pair. /company renders two of these side by side and the
   only thing between them was a collapsed whitespace text node, so "‹ All Jobs" and "All
   Companies" read as one phrase rather than as two destinations. inline-flex means the gap
   property above applies INSIDE each link (icon to label), not between siblings. */
.backlink{display:inline-flex;align-items:center;gap:var(--space-100);color:var(--muted);font-size:var(--text-xs);text-decoration:none;margin:2px var(--space-200) 12px 0;font-weight:500}
.backlink:last-of-type{margin-right:0}
.backlink:hover{color:var(--ink)}
.cohead{display:flex;gap:var(--space-200);align-items:flex-start;margin:0 0 6px}
.cohead-main{min-width:0;flex:1}
.cohead h1{margin:0;line-height:1.2}
.cosub{color:var(--muted);font-size:var(--text-sm);margin-top:4px}
.cochips{margin:11px 0 2px}
.cosearch{flex:1;min-width:150px;max-width:320px;padding:7px 11px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--ink);font:inherit;font-size:var(--text-sm)}
.cosearch:focus{outline:none;border-color:var(--brand)}
#cobtn{margin-top:10px}
/* "More about this employer" panel. Reuses .modal / .modal-head / .sechdr / .kw wholesale —
   only the pieces the job panel has no equivalent of are defined here. */
.costats{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:var(--space-150);margin:8px 0 12px}
.costat{background:var(--surface2);border:1px solid var(--border-card);border-radius:var(--r);padding:11px 12px}
/* These four tiles exist to be COMPARED with each other, which is the definition of a case
   for tabular figures: without them "1,284" and "97" set their digits to different widths
   and the eye has to re-measure each tile instead of reading across the row. */
.costat b{display:block;font-family:var(--font-data);font-size:var(--text-lg);font-weight:var(--weight-semibold);font-variant-numeric:tabular-nums;letter-spacing:var(--tracking-tight);line-height:1.15;color:var(--ink)}
.costat span{display:block;margin-top:3px;font-size:var(--text-2xs);color:var(--muted);line-height:1.35}
/* H-1B approvals by fiscal year. Bars are sized server-side (one % per year), so this needs no
   JS and no chart library — and it prints and scales with the panel. align-items:flex-end is
   what makes them grow from the axis rather than hang from the top. */
.cochart{display:flex;align-items:flex-end;gap:var(--space-050);height:104px;margin:10px 0 6px;
         padding:0 2px 18px;border-bottom:1px solid var(--line);position:relative}
/* RENAMED FROM .cobar, which was a real collision rather than a naming preference. That one
   class named two unrelated components: this chart bar (/company, and now /job) and the sticky
   toolbar on /companies (~line 3020). Both rules were unscoped, so the toolbar's -- LATER in
   the file and of equal specificity -- won every property the two share, and every chart bar
   was being handed position:sticky, a canvas background, a bottom border and 12px of padding.
   Scoping to `.cochart .cobar` was tried first and is NOT enough: it wins the properties this
   rule sets, and the toolbar keeps every property it does NOT -- the background survived and
   the bars were still painted on --bg-canvas. A shared name leaks by default and you only
   notice the half you thought to override. The id #cobar on /companies is untouched;
   companies.js reads it by id, never by class. */
.cochart-bar{flex:1;min-width:0;height:100%;display:flex;align-items:flex-end;position:relative}
/* NO min-height. _company_brief/_company_profile already floor a non-zero year at 2%, so the only bar that
   can be 0 here is a year with genuinely no approvals — and that one has to render as nothing.
   A min-height drew it as a 2px stub identical to a 1-approval year, which is the one thing
   this chart must not say. */
.cochart-fill{width:100%;background:var(--visa-h1b-text);border-radius:var(--radius-xs) var(--radius-xs) 0 0;
            opacity:.85;transition:opacity .15s}
.cochart-bar:hover .cochart-fill{opacity:1}
/* The year label sits below the axis line, so a tall bar never collides with it. */
.cochart-y{position:absolute;bottom:-16px;left:0;right:0;text-align:center;font-family:var(--font-data);font-size:var(--text-3xs);
         color:var(--muted);font-variant-numeric:tabular-nums}
.coprose{font-size:var(--text-sm);line-height:1.6;color:var(--ink);margin:0 0 10px}
.coprose.small{font-size:var(--text-xs);color:var(--muted)}
.colist{margin:0 0 12px;padding-left:20px;font-size:var(--text-sm);line-height:1.55;color:var(--ink)}
.colist li{margin:0 0 5px}
/* The card's company name is a link now. Underlining every one of them turns the feed into a
   wall of rules, so it reads as plain emphasis until you point at it. */
/* color:inherit is load-bearing now that the accent is blue. Without it these picked up the
   global link colour and every company name on a blue card was blue text on blue, which is
   the opposite of what the rule below intends. It reads as emphasis until you point at it. */
.cmeta a{color:inherit;font-weight:600;text-decoration:none}
.cmeta a:hover{text-decoration:underline;text-underline-offset:2px}
/* ---- feed layout ----
   One column. The 280px rail column is gone and so is every rule that served it: .railtop,
   .railhead, .railtitle, .railtoggle, .railbody, .railfoot, .railstrip, .railcollapsebtn,
   .fcaret, the four `.rail X` overrides, the collapse-to-a-strip block and the two media
   queries that sized a grid track for it. None of that had been rendered by any template
   since the chip bar landed; it was roughly 90 lines of CSS for markup that did not exist.

   .feedlayout is kept as a named block rather than deleted so .results keeps its min-width:0
   guard and the popovers keep a positioned ancestor to be absolute inside. */
.feedlayout{display:block;position:relative}
.results{min-width:0}
.matchrow{display:flex;align-items:center;justify-content:space-between;gap:var(--space-100)}
.countrow{display:flex;align-items:center;justify-content:space-between;gap:var(--space-150);margin:14px 0 8px}
.results .tabs{margin-top:0}
.results .count{margin:0}

/* ---- wide displays ----
   Above ~1900 the 1880 cap starts leaving gutters again, so it widens. The column count
   follows from the grid's own arithmetic (300px min track + 24px gap = 324px a column, so n
   columns need 324n-24 of track): 5 at 1920, 7 at 2560.
   The cap only. There is no rail column to widen any more, so the extra width goes entirely
   to the card grid, which is what the chip bar bought in the first place. */
@media (min-width:1900px){ .feedwrap{max-width:2280px} }

/* career-track segmented control — the feed's primary split, so it reads as one solid object */
.seg{display:inline-flex;background:var(--surface2);border:1px solid var(--line);border-radius:var(--radius-full);padding:3px;gap:var(--space-025)}
.segb{font:inherit;font-size:var(--text-sm);font-weight:600;color:var(--muted);background:transparent;border:0;border-radius:var(--radius-full);padding:7px 14px;cursor:pointer;white-space:nowrap;touch-action:manipulation;transition:background var(--t-fast) var(--ease),color var(--t-fast) var(--ease),box-shadow var(--t-fast) var(--ease)}
.segb:hover{color:var(--ink)}
/* Solid ink for the chosen track. A tint would be indistinguishable from the unselected
   buttons now that the selection tint is itself a neutral. */
.segb.on{background:var(--accent);color:var(--text-on-accent);box-shadow:none}
.segb:focus-visible{outline:2px solid var(--accent);outline-offset:1px}

/* "Filters" disclosure (only rendered below 900px) + its active-count badge */
.fbadge{background:var(--brand);color:var(--on-brand);border-radius:var(--radius-full);font-size:var(--text-3xs);font-weight:800;line-height:1;padding:3px 6px;min-width:16px;text-align:center}
.fbadge[hidden]{display:none}
.btn.fset{border-color:var(--accent);color:var(--ink)}
.locin.fset{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
.sel{width:auto;font-size:var(--text-sm);font-weight:500;padding:9px 11px;cursor:pointer}
.sel:hover{border-color:var(--line-strong)}
.ck{display:inline-flex;align-items:center;gap:var(--space-100);font-size:var(--text-xs);cursor:pointer;white-space:nowrap;color:var(--muted)}
.ck input{width:auto;accent-color:var(--accent)}
.matchfilter{display:flex;flex-direction:column;gap:var(--space-100)}
.rngval{font-family:var(--font-data);font-size:var(--text-2xs);font-weight:var(--weight-semibold);font-variant-numeric:tabular-nums;color:var(--text-on-accent);background:var(--accent);border-radius:var(--radius-sm);padding:3px 9px;min-width:46px;text-align:center}
/* ACTIVE-FILTER HIGHLIGHT (app.js toggles .fset on any control set off its default).
   It used to be a tinted wash, which no longer exists: the selection tint is a neutral now,
   and a neutral wash on a neutral field says nothing. A doubled ink edge and a heavier
   label carry it instead, which is legible in both themes and reads as "you set this"
   rather than as "this is highlighted". */
.sel.fset{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent);color:var(--ink);font-weight:600}
.sel.fset:hover{border-color:var(--accent)}
.ck.fset{color:var(--ink);font-weight:600}
/* smooth custom range slider */
/* width:100% is set here rather than via a `.rail .ranged` override: that would tie on specificity
   (0,2,1 vs 0,2,1) and lose on source order. .ranged only ever appears in a filter popover. */
input[type=range].ranged{-webkit-appearance:none;appearance:none;background:transparent;width:100%;height:22px;padding:0;margin:0;cursor:pointer}
.ranged::-webkit-slider-runnable-track{height:6px;border-radius:var(--radius-full);background:linear-gradient(to right,var(--brand) 0 var(--p,60%),var(--line-strong) var(--p,60%) 100%)}
.ranged::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;margin-top:-6px;border-radius:var(--radius-full);background:var(--surface);border:2px solid var(--brand);box-shadow:0 1px 3px rgba(20,23,31,.25);transition:transform .14s ease,box-shadow .14s ease}
.ranged:hover::-webkit-slider-thumb{transform:scale(1.12)}
.ranged:active::-webkit-slider-thumb{transform:scale(1.22);box-shadow:0 0 0 6px var(--brand-soft)}
.ranged:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 4px var(--brand-soft)}
.ranged::-moz-range-track{height:6px;border-radius:var(--radius-full);background:var(--line-strong)}
.ranged::-moz-range-progress{height:6px;border-radius:var(--radius-full);background:var(--brand)}
.ranged::-moz-range-thumb{width:18px;height:18px;border-radius:var(--radius-full);background:var(--surface);border:2px solid var(--brand);box-shadow:0 1px 3px rgba(20,23,31,.25)}
.ranged:active::-moz-range-thumb{box-shadow:0 0 0 6px var(--brand-soft)}
.ranged:focus-visible{outline:none}
.tabs{display:flex;gap:var(--space-050);margin:16px 0 4px;flex-wrap:wrap}
/* `transition:.15s` is shorthand for `transition: all .15s`, which animates width, height
   and every inherited property on hover and forces layout each frame. Listed explicitly. */
.tab{padding:8px 16px;border-radius:var(--radius-full);background:transparent;border:1px solid transparent;font:inherit;font-size:var(--text-sm);font-weight:600;color:var(--muted);cursor:pointer;touch-action:manipulation;transition:background var(--t-fast) var(--ease),color var(--t-fast) var(--ease),border-color var(--t-fast) var(--ease)}
.tab:hover{color:var(--ink);background:var(--surface2)}
.tab.on{background:var(--bg-active);color:var(--ink);border-color:transparent}
.tabn{background:var(--surface2);border:1px solid var(--line);color:var(--muted);border-radius:var(--radius-full);padding:0 7px;font-family:var(--font-data);font-size:var(--text-3xs);font-variant-numeric:tabular-nums;font-weight:var(--weight-semibold);margin-left:6px}
.tab.on .tabn{background:var(--surface);border-color:transparent;color:var(--ink)}
.banner{background:var(--warn-bg);border:1px solid var(--warn-line);padding:11px 15px;border-radius:var(--r);margin:12px 0;font-size:var(--text-sm)}
/* Tabular figures so the count doesn't jiggle its neighbours as it counts down while a
   filter is dragged. */
.count{color:var(--muted);font-size:var(--text-xs);margin:16px 2px 4px;font-weight:500;font-variant-numeric:tabular-nums}
.count #count,.count #counttot{font-family:var(--font-data);font-weight:var(--weight-semibold);color:var(--ink)}
.count #counttot{font-weight:var(--weight-regular);color:var(--muted)}

/* ---- THE FEED: ONE ROW PER JOB ------------------------------------------------------
   This was repeat(auto-fill,minmax(330px,1fr)) until 2026-09-03 -- four to five narrow cards
   per row, which was itself a deliberate purchase: templates/feed.html records that deleting
   the 280px filter rail bought "a permanent ~33% more jobs per screen".

   THE ARITHMETIC WAS RIGHT AND IT PRICED THE WRONG THING. A card you have to open is a lead,
   not a result -- but what was missing was never COLUMNS, it was facts. Four cards carrying six
   facts each is twenty-four facts and four pending clicks.

   So this is the middle setting, at the owner's direction after seeing both: THREE to a line,
   and every card carries its pay, its place and its years. It spent an afternoon as one
   full-width row per job, which fitted the same facts and about five jobs a screen against
   roughly fifteen here -- and with pay on 41% of rows and remote on 17%, that row's fact
   cluster used its left 45% and the remaining 700px held nothing.

   FIXED TRACKS STILL DO THE WORK, and a uniform grid is what keeps them honest: every card is
   the same width, so .cfacts resolves to identical offsets in all three columns and pay sits
   under pay both across a line and down the page. That is the property the 330px auto-fill
   grid never had -- its cards were islands whose titles wrapped to different heights and whose
   meta line was a run-on. */
.feedgrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-200)}
/* Flatter and rounder: at rest the card carries no shadow and separates from the page by
   radius and fill alone, so a dense grid reads as one calm surface instead of dozens of
   floating tiles. Elevation is reserved for hover, where it means "this one".

   content-visibility lets the browser skip layout and paint for cards scrolled out of
   view, which matters on a grid that routinely runs past a hundred. contain-intrinsic-size
   is the height it should ASSUME for a skipped card — without it the scrollbar jumps as
   each one is realised.

   IT SIZES THE CONTENT BOX, NOT THE BORDER BOX, and that is worth writing down because getting
   it wrong is self-concealing. Set to 164 while the real content box was 140, every card the
   viewport had not reached measured 201px in the DOM and every card it had measured 178 -- so
   the feed reported two different card heights at once and the taller one was pure fiction.
   MEASURED over 60 live rows at 1440px with content-visibility forced off, three to a line:
   median border box 308px (285/304/308/328 — a grid row stretches every card to the tallest in
   its line, so the spread is between LINES, not noise). 308 - 18*2 padding - 2*1 border = 270. */
/* will-change moved OFF this rule and onto :hover below. It is a hint to promote the element to
   its own compositor layer, and it applied to EVERY card -- 60 on first paint and 60 more per
   Load more, so a scrolled feed asked the compositor to hold hundreds of layers for a 3px lift
   that at most one card is using at a time. The MDN warning is exactly this case: applied
   broadly and left on, will-change costs more than it saves. On :hover the promotion happens
   when the lift is about to run and is released when the pointer leaves. */
/* WHITE, WITH AN OUTLINE. Every card was washed in --route-h1b-fill; the blue moved to
   .cardverdict on 2026-09-03 at the owner's direction. The rule it serves is unchanged and
   better served: ONE hue, spent on the one thing that is NOT the employer's -- our reading of
   the job -- instead of tinting the whole surface and distinguishing nothing.
   padding:0 because the panel has to reach its own edges; the white half carries its padding
   in .cardbody. overflow:hidden is what squares the panel off inside the card's radius. */
.card{position:relative;background:var(--surface);border:1px solid var(--border-card);border-radius:var(--radius-lg);padding:0;display:flex;flex-direction:row;align-items:stretch;overflow:hidden;cursor:pointer;box-shadow:var(--shadow-xs);content-visibility:auto;contain-intrinsic-size:auto 214px}
.cardbody{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:var(--space-100);padding:18px}
/* A COLUMN, NOT A CORNER. 156px, and MEASURE IT rather than scaling the old number -- 148 was
   arrived at by assuming 11px type would need proportionally less than 12px did, and it was 3px
   short, so every "Sponsorship likely" on the feed rendered as "Sponsorship likel...". Measured
   at nowrap with the tick included: 113 / 125 / 127px ("Sponsorship unlikely" widest) + 24 of
   padding = 151, so 156 with a little slack. At 12px the same three were 123 / 136 / 138 and
   needed 164. Undersize it and the ellipsis hides the word that carries the meaning.
   min-width:0 IS LOAD-BEARING AND IT IS NOT OBVIOUS. A flex item's automatic minimum size is
   its MIN-CONTENT width, so `flex:0 0 152px` is a floor and not a width: "Sponsorship unlikely"
   measured 161px and simply widened the panel, while "Sponsorship likely" left it at 146. Three
   different panel widths meant three different BODY widths on cards that are all 599px, and the
   fact cells stopped landing at the same offsets -- 19,225 / 19,226 / 19,232 across the grid,
   which is the one thing the fixed tracks exist to prevent. Measured, not spotted by eye. */
.cardverdict{flex:0 0 156px;min-width:0;display:flex;flex-direction:column;align-items:center;
  justify-content:center;gap:var(--space-050);padding:18px 12px;text-align:center;
  background:var(--verdict-bg);color:var(--verdict-ink);border-left:1px solid var(--verdict-line)}
/* The ring is repainted from custom properties scoped here, so scoreRing() itself is UNTOUCHED
   -- it is text-lifted out of app.js and executed under node by test_card_meta, and the safest
   change to it is none at all. 72px via width/height on the SVG rather than a bigger viewBox:
   the arc and the number scale together, so the % grows with the ring instead of stranding 10px
   type in a large circle.
   --match-pill is the opaque disc behind the arc. It exists so a coloured ring stays legible
   against whatever it is drawn on, so here it is the panel's own tint -- transparent would work
   too, but naming the colour keeps the contrast pair below meaningful. */
.cardverdict .score-ring{width:72px;height:72px;--match-pill:var(--verdict-bg);--ring-track:var(--verdict-line)}
/* ONE RING COLOUR, and the label carries strong/good/low. Three tints of white on one blue is a
   distinction nobody can read, and the words underneath are unambiguous and already there. */
.cardverdict .ring-strong,.cardverdict .ring-good,.cardverdict .ring-low{--ring-color:var(--verdict-ink)}
.cardverdict .score-pending,.cardverdict .score-none{width:64px;height:64px;
  color:var(--verdict-sub);background:transparent;border-color:var(--verdict-line)}
/* The tick is the panel's ink; the words beside it are the quieter sub colour, so the line
   reads as one claim rather than two weights of the same sentence. */
/* The ring's own 70/40 thresholds in words, on the panel with it.
   A SECOND .cardverdict RULE USED TO SIT HERE, left over from the version where the verdict was
   a top-right corner rather than a column: `flex:0 0 auto`, 24 lines after the real rule and
   therefore winning on source order. The panel silently sized to its own text -- 146px next to
   "Sponsorship likely", 161px next to "Sponsorship unlikely" -- so cards that were all 599px
   wide had three different BODY widths and the fact cells stopped sharing an offset. Nothing
   looked broken; the alignment was just quietly not there. Deleted. */
.cvlabel{font-size:var(--text-2xs);font-weight:var(--weight-bold);text-transform:uppercase;
  letter-spacing:var(--tracking-wide);color:var(--verdict-ink);line-height:1.3}
/* ---- THE FACT GRID ----
   FIXED TRACKS, and an unknown value renders an EMPTY cell rather than a narrower row. That is
   the entire mechanism: pay sits under pay all the way down the feed, so ten jobs compare
   without reading ten cards. Let the empties collapse and row 3's salary slides under row 2's
   location, which is the ragged run-on this replaced.
   FOUR TRACKS, NOT SIX. There is no employment-type field anywhere in this corpus, and `remote`
   is a boolean — core.parse_location drops "hybrid"/"onsite" as noise when it picks a city — so
   a work-mode column with three states would have to be invented. An empty column is honest.
   display:block, not flex, on the cell: text-overflow only fires on a block container, and
   "Minneapolis Minnesota United States of America" has to be able to clip. */
/* TWO TRACKS AND TWO ROWS, in DOM order: place / years on the first line, pay / remote on the
   second -- see cardHTML, which orders the cells by how often they are POPULATED rather than by
   importance, so the 52% of cards carrying exactly two facts fill line one and leave line two
   empty instead of sitting diagonally opposite. Four tracks across one line was right for a full-width row and is wrong here -- at
   three to a line a card is ~440px at 1440, so four columns give each fact ~100px and every
   value ellipses to nothing.
   EQUAL HALVES, and still fixed: location is the only cell that ever needs to clip, half a card
   is enough for "O'Fallon, Missouri", and pay keeps an offset that is identical in all three
   columns.
   MEASURED over 120 live rows: location 98%, years 91%, pay 41%, remote 17%. So 52% of cards
   fill exactly two of the four cells -- a blank is the COMMON case, which is why it holds its
   slot instead of collapsing and letting pay slide under place. */
.cfacts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));
  gap:var(--space-050) var(--space-150);margin-top:var(--space-025)}
.cfact{display:block;min-width:0;font-size:var(--text-xs);color:var(--text-secondary);
  line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* NO ICONS. Each cell led with a real emoji (\01F4CD place, \01F4C5 years, \01F4B2 pay,
   \01F3E0 remote) until 2026-09-03; removed at the owner's direction. The .cf-* classes stay on
   the cells as hooks -- they cost nothing and the fact ORDER is asserted through them -- but
   nothing draws them. What separates the cells now is the fixed grid track, which was always
   the thing doing the real work. */

/* ---- THE CARD WASH ------------------------------------------------------------------
   ONE RULE, because the wash carries no information any more. It used to tint each card by the
   sponsorship route it names; the chip names the route in words, and painting the same fact
   twice bought a four-colour grid for nothing.

   The per-route overrides are DELETED rather than left resolving to the same colour. Four dead
   rules sitting next to a palette that no longer varies is how somebody later concludes the
   distinction is still meant to exist and re-colours one of them.

   Scoped on the ATTRIBUTE, not on .card. Resume Brain reuses the .card class for its panels,
   and a bare `.card` rule would have tinted all of them.

   data-route STAYS on the article even though nothing here reads its value: the swipe deck and
   scripts/test_jdrender.py key off it, and leaving the hook costs nothing. */
/* data-route STAYS on the article -- web.py::_route_of mirrors it, scripts/test_job_page.py
   asserts the two agree, and the sponsorship filter reads the same field -- but it paints
   nothing now. It is data, not decoration. */

/* Hover deepens the card's OWN colour rather than swapping its border for a neutral, which
   would wipe the route on the one interaction where the reader most wants to still see it. */
.card:hover{box-shadow:var(--shadow-md);transform:translateY(-3px);will-change:transform}

/* --- ARRIVAL ---------------------------------------------------------------------------
   Cards fade up 8px as they land. app.js sets animation-delay per card (capped at 10 steps
   of 18ms in animateIn, so the 60th card does not wait a second) and adds .card-in only to
   the ones that are actually new -- a Load more animates the appended page, not the whole
   feed. `both` holds the from-state during the delay so a card cannot flash in and then
   restart.

   The reduced-motion rule below is NOT redundant with the global override at the top of this
   file. That one clamps animation-duration but says nothing about animation-DELAY, so with
   `both` still in force a card would sit at opacity 0 for its full delay and only then snap
   in -- turning "less motion" into "content appears late", which is worse than the animation
   it was meant to remove. animation:none drops the fill mode with it. */
@keyframes cardin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.card.card-in{animation:cardin .18s var(--ease-out) both}
@media (prefers-reduced-motion:reduce){.card.card-in{animation:none}}

/* The brand mark fades when the image itself decodes, which is later than the card for a
   lazily-loaded logo. NO fill mode and no opacity on .cmark itself, deliberately: the resting
   state is fully visible, so if the load event is ever missed -- a cached image that decodes
   before app.js attaches its listener, say -- the logo simply appears without a fade. A fade
   that can leave a logo permanently invisible would be a much worse bug than a pop. */
@keyframes markin{from{opacity:0;transform:scale(.94)}to{opacity:1;transform:none}}
.cmark.mark-in{animation:markin .22s var(--ease-out)}

/* APPLY AT FULL STRENGTH. The card wears the pale blue, the button wears the saturated one,
   and between them no black is left anywhere in the grid. Every state gets a FILLED button --
   a hollow one reads as disabled, and no sponsorship verdict means you cannot apply. */
/* Scoped on [data-apply] rather than on .btn.primary, and on the ATTRIBUTE rather than on
   .card. Two reasons.
   - The /job page carries data-route on its wrapper, so its two Apply buttons inherit these
     rules with no second selector list. There used to be a .card list and an identical
     .modal-overlay list doing the same job; that was the drift waiting to happen.
   - Not every primary button under a route is an Apply. The job page's "Tailor for X" is a
     primary action too, and painting it with the sponsorship colour would say something about
     the route that the button has nothing to do with. data-apply marks precisely the outbound
     link to the employer, on the card and on the page alike. */
[data-route] .btn.primary[data-apply]{background:var(--route-h1b-ink);border-color:var(--route-h1b-ink);color:var(--route-on-ink)}
/* One veil rather than a second shade per route, so adding a route later needs no new token. */
[data-route] .btn.primary[data-apply]:hover{background-image:linear-gradient(var(--ink-veil),var(--ink-veil))}

/* Everything that was drawn for a plain white surface and now sits on a tint. Grouped here
   so there is one place to look when a new element is added to a card and comes out as a
   pale rectangle floating on the colour. */
.card[data-route] .btn:not(.primary):hover,
.card[data-route] .ico:hover{background:var(--on-card-veil)}
/* A card renders at most two chips now -- the sponsorship verdict, and Closed on a dead
   posting -- so this list is down to the one of them that is neutral. The rest still exist and
   are still styled; they render on /job, which is not inside a .card. */
/* THE HAIRLINE IS WHAT KEEPS THE VERDICT CHIP A CHIP.
   Every card wears the blue wash now, and in DARK the chip fill (#152340) and the card fill
   (#18253f) are two steps apart -- so "Sponsorship likely" lost its pill and read as loose
   blue text floating on the card. Measured, not eyeballed: the light theme is fine because its
   chip is LIGHTER than the card it sits on and dark inverts that relationship.
   currentColor rather than a border token, so each chip outlines itself in its own ink -- blue
   for likely, grey for unlikely -- in both themes, with nothing new to keep in sync. The fills
   stay, because grey-vs-blue is the fastest read on the card and the border only restores the
   edge that the one-hue card took away. */
.card[data-route] .spon,.card[data-route] .nospon{
  border:1px solid color-mix(in srgb,currentColor 22%,transparent)}

/* The detail panel keeps a plain surface — it is a reading view, and a tint under 7,000
   characters of description is a different proposition from a tint on a card — but its Apply
   carries the route, so the action does not change colour on the way in. */
.modal-overlay[data-route] .btn.primary{background:var(--route-h1b-ink);border-color:var(--route-h1b-ink);color:#fff}

/* "New" — today's freshly pulled jobs. Rides IN the top row between the logo and the score
   rather than hanging off the card's edge, because an overhanging sticker was the one piece
   of card furniture that read as decoration rather than as data. */
.newflag{font-family:var(--font-data);font-size:var(--text-3xs);font-weight:var(--weight-semibold);
  letter-spacing:.08em;text-transform:uppercase;color:var(--text-on-accent);background:var(--accent);
  padding:3px 9px;border-radius:var(--radius-full);line-height:1.4;white-space:nowrap}

/* Hide = the card visibly leaves, then the grid reflows.
   Fade + scale rather than a height collapse. .feedgrid is
   `repeat(3,minmax(0,1fr))`, so cards are grid items laid out in tracks: folding
   one to zero height doesn't close the gap the way it would in a vertical list, because the
   remaining items reflow across the columns regardless. Transform and opacity also stay off
   the layout path, so this can't jank a 60-card grid. */
.card-collapse{pointer-events:none;
  transition:opacity var(--t) var(--ease), transform var(--t) var(--ease-collapse)}
.card-collapse.gone{opacity:0;transform:scale(.94) translateY(-8px)}
/* Undo lives in the toast, so the action stays reversible without a modal. */
.toast .undo{margin-left:12px;background:transparent;border:0;padding:2px 6px;border-radius:var(--radius-xs);
  font:inherit;font-size:var(--text-xs);font-weight:700;color:var(--brand);cursor:pointer;text-decoration:underline}
.toast .undo:hover{background:var(--brand-soft)}
/* Loading skeleton — rendered server-side inside #feed so the user sees card-shaped
   placeholders the instant the page paints; app.js replaces them once it renders the feed. */
.skel{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);padding:16px 17px;display:flex;align-items:flex-start;gap:var(--space-150);min-height:118px}
.skel-lines{flex:1;display:flex;flex-direction:column;gap:var(--space-150);padding-top:3px}
.skel-bar{height:11px;border-radius:var(--radius-xs);display:block}
.skel-bar.w70{width:70%}.skel-bar.w55{width:55%}.skel-bar.w40{width:40%}
.skel-chip{width:44px;height:22px;border-radius:var(--radius-full);flex:0 0 auto}
.skel-bar,.skel-chip{background:linear-gradient(90deg,var(--line) 25%,var(--line-strong) 37%,var(--line) 63%);background-size:400% 100%;animation:skelsh 1.6s ease-in-out infinite}
@keyframes skelsh{0%{background-position:100% 0}100%{background-position:-100% 0}}
@media (prefers-reduced-motion:reduce){.skel-bar,.skel-chip{animation:none}}
/* UP TO THREE children and only the score is always present -- which is the whole reason
   margin-left:auto is back below. The logo leads this row again as of 2026-09-02, at 24px
   rather than the 36px-tall/180px-wide lockup that failed here (61% of a 294px card for a
   wordmark); the New flag follows it, and space-between alone was never enough to hold the
   score right.

   IT IS NOT, AND THE COMMENT THAT USED TO SIT HERE SAID THE OPPOSITE: "with no flag the score
   is alone and right-aligned". A single flex child under justify-content:space-between is laid
   out at main-START, not main-end -- space-between only distributes the FREE SPACE BETWEEN
   items, and one item has none. So every card without a New flag drew its ring or its "JD
   pending" chip on the LEFT, and the feed switched sides depending on how old the newest job
   on screen happened to be. Reported from the live site 2026-09-02: the default feed (fresh
   jobs, flags present) put the score right, and any search that surfaced older rows put it
   left.

   THE SCORE IS NOT IN HERE ANY MORE and the three margin-left:auto rules went with it. It
   holds the right edge of the ROW now, in .cardverdict, which is a real column with a rule down
   its left side rather than a corner reached by an auto margin.
   .cardtop went too. Once the score left, it was a 24px strip holding one 24px logo — ~32px of
   every card, gap included, to say nothing — so the mark moved onto the title's line and the
   New flag onto the identity line under it. MEASURED: 201px per card before, 169px after, on a
   surface whose entire purpose is answers per screen. */
/* NO FIXED SLOT FOR THE MARK, and companyMark's own note is right again: nothing sits beside
   it now. The title spans the card on the next line, so every title in the grid starts at the
   same x for free -- and a mark that is 39px wide for Mastercard and absent on 15% of rows
   costs nothing. The 104px reserved slot was needed only while mark and title shared a line;
   on a ~440px card it would be a quarter of the width held open for a logo. */
.cardlede{display:flex;align-items:flex-start;gap:var(--space-100);min-width:0}
/* The title is a real <a href="/job?u=..."> now. It was a <button> opening the modal, and before
   that a bare <article> click handler that no keyboard could reach. The button resets are gone
   with the button; what stays is the two-line clamp, the inherited ink colour, and CRUCIALLY the
   focus ring, which is the entire reason the title was promoted to a control in the first place.
   Never add outline:none here without a focus-visible replacement. */
/* --text-base, DOWN FROM --text-md. 18px was right when a card was 599px wide; at three
   columns on a 1536px laptop the white half is ~336px, and 18px put two words on a line and
   clamped the rest. The whole card came down one step TOGETHER -- title 16, company 13, date
   12, facts 13, panel 11 -- so the relationships are unchanged and only the scale moved. The
   owner's own framing: adjust the words, not the layout.
   No min-height: the 42px floor that reserved a second line for a short title is dead weight
   now that the title has the card's full width. The two-line clamp stays for the long ones. */
.ctitle{display:-webkit-box;width:100%;text-align:left;text-decoration:none;
  font-weight:600;font-size:var(--text-base);line-height:1.3;letter-spacing:-.01em;
  color:var(--ink);cursor:pointer;text-wrap:balance;
  -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.ctitle:hover{text-decoration:underline}
.ctitle:focus-visible{outline:none;box-shadow:var(--focus);border-radius:var(--radius-xs)}
/* Reaching any control inside a card lifts the whole card, so a keyboard user sees the same
   "this one" the mouse gets on hover instead of a ring floating on an otherwise flat grid. */
.card:focus-within{box-shadow:var(--shadow-md);border-color:color-mix(in srgb,currentColor 30%,var(--border-default))}
/* ONE SCALE ON THE CARD, top to bottom: title 16 / company 13 / date 12 / facts 13 / panel 11.
   It had accumulated four unrelated sizes across five rows -- 18, 13, 11, and an inherited 13
   in the fact grid -- which is what read as unfinished rather than dense. Standardised at
   18/14/13/14/12 first, then moved down one step together on 2026-09-04 so three columns fit a
   1536px laptop; the RELATIONSHIPS are the point, not the absolute numbers. */
.cmeta{color:var(--muted);font-size:var(--text-xs);line-height:1.6}
.cident a{font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--ink);
  text-decoration:none}
.cident a:hover{text-decoration:underline}
/* The meta-row separator. Set apart from the words it divides so the eye reads three facts
   rather than one run-on string, and given real breathing room instead of the single spaces
   it used to be typed between. */
/* --border-strong, not --border-default: default is a hairline tuned to be nearly invisible
   against a surface, which is right for a rule and wrong for a glyph — in dark mode the dot
   disappeared entirely and "Medtronic Minneapolis" ran together as one phrase. Strong still
   sits below the text in both themes, so it separates without competing. */
.sep{color:var(--border-strong);padding:0 var(--space-050);font-weight:var(--weight-regular)}
/* IDENTITY LINE: company, place, when. Exactly one line, always. The location is the only
   flexible item, so it is the one that gives when an employer writes "Minneapolis Minnesota
   United States of America" — everything else keeps its full width and the row can never
   wrap a bare separator dot onto the start of a new line. min-width:0 on both the row and
   the shrinking child is what actually lets text-overflow fire inside a flex container. */
/* overflow:hidden is not cosmetic here, it is what stops the row sizing the whole card.
   A grid item's automatic minimum size is its MIN-CONTENT width, and an unbreakable nowrap
   run of "Medtronic · Minneapolis Minnesota United States of America · 4d ago" measures
   426px — so on a 375px phone the track was forced to 468px and the entire page scrolled
   sideways. Clipping the row drops its min-content contribution to zero, and the track goes
   back to obeying the grid. .cloc still gives first, so the clip is a backstop rather than
   the mechanism. */
.cident{display:flex;align-items:baseline;min-width:0;overflow:hidden;white-space:nowrap}
.cident > *{flex:0 0 auto}
/* THE EXPERIENCE, AS INK. Deliberately NOT .exp / .exp-lo / .exp-mid / .exp-hi, which are the
   /job page's chip: those carry a background and a route colour, and the card's rule is one
   blue and one chip. This is a fact on the identity line beside the place and the date, so it
   is styled like them — muted, no background, no border.
   Only the third state is marked at all, and it is marked by ITALIC rather than by colour:
   "years not stated" is the card admitting it could not read the posting, and the reader has
   to be able to see that at a glance under an experience filter without a second hue arriving
   on the card to say it. The number itself gets the data font for the same reason .exp does —
   tabular figures so a column of cards lines up. */
.cexp{font-family:var(--font-data);font-variant-numeric:tabular-nums;letter-spacing:0}
/* The inferred case is the only non-numeric one that still renders, and italic is what marks it
   as read off the title rather than off the posting. The three no-answer states print nothing
   at all now, so they have no rule here. */
.cexp-inferred{font-family:inherit;font-variant-numeric:normal;font-style:italic}
/* THE BRAND MARK, ON THE LINE IT IDENTIFIES. Fixed HEIGHT and a hard WIDTH CAP, which is the
   difference from .colock below: that rule caps width at 180px only, so everything between
   about 3:1 and 6:1 -- 46% of the 1,148 harvested assets -- rendered at full height AND near
   full width. Walmart was 180x35 in a 294px card. Capped at 20x80 it is 80x15, and the eye
   reads it as part of the text run rather than as a banner over it.
   Recognition, not legibility, is the job here: the company's name is the very next thing on
   the line, so the mark only has to be identifiable, and that is what buys the small size.
   No margin-right: .cardtop supplies the gap, and keeping one here put the New flag a step
   further out than the row's own rhythm. align-self:center is kept rather than trimmed as
   dead weight -- it was load-bearing on the baseline-aligned .cident this moved off, and it
   is what stops the mark riding high if it is ever put back next to text. */
/* THE CAP IS WHAT MAKES A WORDMARK SMALL, not the height -- and it was doing it to a third of
   them. With height:32 fixed, any mark wider than cap/32 hits the width first and is scaled
   DOWN, so it renders shorter than the box: at 132 that was every logo past 4.12:1.
   MEASURED over the 1,770 harvested marks (static/logos/index.json): aspect p50 2.45, p90 6.10,
   max 11.90. At a 132px cap, 526 of 1,770 (30%) were height-crushed and the worst rendered
   11.1px tall in a 32px row. At 176 it is 245 (14%) and the worst is 14.8px.
   176 AND NOT MORE: the mark sits alone on its line, so the cap only has to fit the body -- 176
   of a 446px body at three columns. Past ~200 it starts to read as a banner over the title
   rather than a mark beside the name, which is the failure the 2026-09-02 note below describes.
   The remaining 14% are 6:1-and-wider wordmarks; no cap fixes those without distorting them. */
.cmark{align-self:center;display:block;height:32px;width:auto;max-width:176px;
  object-fit:contain;object-position:left center;border-radius:2px}
/* Same two-state dark-mode rule as .colock img, at the smaller scale and for the same reason:
   most brand logos are drawn in dark ink for white paper, a single-ink mark can be inverted
   because it has no brand colour to protect, and a full-colour one cannot. content-box so the
   plate grows AROUND a 20px mark instead of shrinking the artwork to 16px. */
[data-theme="dark"] .cmark{background:var(--logo-plate);
  box-shadow:inset 0 0 0 1px var(--logo-plate-edge);padding:2px 3px;box-sizing:content-box}
[data-theme="dark"] .cmark[data-mono="1"]{background:transparent;box-shadow:none;
  filter:invert(1) brightness(1.9)}
/* .cloc is gone with the run-on identity line: the location is a .cfact now, and .cfact does
   the clipping for all four cells rather than each one bringing its own rule. */
/* The card is the grid item, so it carries the same guard one level up. */
.card,.cmeta{min-width:0}
/* CHIP ROW: a real flex row with a real gap. The chips each carry margin-left:6px for the
   inline runs they appear in elsewhere, which here would double the gap and indent the
   first one, so it is cleared — the same fix .kw already makes. */
/* IN THE PANEL NOW, as a short list under a divider rather than a pill row under the facts.
   A pill needs a background to separate it from its surroundings; on a solid panel the panel
   already does that, so the background comes off and a tick does the work the pill shape was
   doing -- this is a claim, and here is what it is. */
.cbadges{display:flex;flex-direction:column;align-items:center;gap:2px;width:100%;
  margin-top:var(--space-100);padding-top:var(--space-100);
  border-top:1px solid var(--verdict-line)}
/* nowrap, with an ellipsis behind it. The panel is sized to the widest label we actually
   render, so nothing wraps today; the ellipsis is what stops a longer one overflowing the panel
   rather than breaking mid-phrase. */
.cardverdict .cbadges > *{margin-left:0;background:transparent;border:0;padding:0;
  max-width:100%;color:var(--verdict-sub);font-size:var(--text-3xs);
  font-weight:var(--weight-semibold);line-height:1.35;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cardverdict .cbadges > *::before{content:"\2713";margin-right:5px;color:var(--verdict-ink)}
.cardverdict .topspon{color:var(--verdict-ink)}
.cbadges > *{margin-left:0}
/* Dates and pay are things you compare down a column, so they get the data face and figures
   that hold their width while the feed renders. */
.cmeta .posted{font-family:var(--font-data);font-size:var(--text-2xs);font-variant-numeric:tabular-nums;letter-spacing:0}
/* "Added <date>" — this employer publishes no posting date (Tesla's careers API has no date
   field at all), so the card shows when the job reached us instead. Italic + a dotted underline
   so it can never be misread as a posting date. Plain .posted has no rule of its own: it just
   inherits .cmeta's muted grey, and that difference is the point. */
.posted.added{font-style:italic;opacity:.85;text-decoration:underline dotted;text-underline-offset:2px;text-decoration-color:var(--line-strong);cursor:help}
.chip{font-weight:700;padding:6px 10px;border-radius:var(--radius-sm);font-size:var(--text-sm);line-height:1}
.chip.big{display:inline-block;font-size:var(--text-md);padding:9px 14px}
/* BADGE. One shape for all twelve; only the colour pair varies. These twelve classes each
   repeated the same five declarations before 2026-08-09.

   The rule that makes the row readable: COLOUR HERE MEANS VISA ROUTE AND NOTHING ELSE. Every
   other attribute (internship, pay, remote, agency, closed, experience) is a neutral tint on
   purpose, so a sponsorship signal is findable at a glance in a grid of hundreds of cards. */
.h1b,.cx,.nospon,.spon,.ev,.intl,.vt,.agency,.pay,.rem,.closed,.exp,.repost,.jdadmit{
  display:inline-block;font-size:var(--text-3xs);font-weight:var(--weight-semibold);
  padding:2px 8px;border-radius:var(--radius-xs);margin-left:6px;white-space:nowrap;
  vertical-align:middle}
.h1b,.cx,.spon{background:var(--visa-h1b-bg);color:var(--visa-h1b-text)}
.intl{background:var(--visa-unknown-bg);color:var(--visa-unknown-text)}
/* Visa routes. The temporary work visas (H-1B, E-3, H-1B1) share the blue family so they read
   as one group; green card gets the brand green because it's the materially different outcome;
   E-Verify keeps the .ev success green it has always used. */
.vt-h1b{background:var(--visa-h1b-bg);color:var(--visa-h1b-text)}
.vt-e3,.vt-h1b1{background:var(--visa-e3-bg);color:var(--visa-e3-text);opacity:.85}
.vt-green_card{background:var(--visa-gc-bg);color:var(--visa-gc-text)}
.vt-stem_opt{background:var(--visa-stem-bg);color:var(--visa-stem-text)}
/* The card's collapsed "Sponsor Likely" chip. Takes the Green Card plum because a PERM filing
   is what it means for almost every employer in that bucket; e3 and h1b1 fold in behind it.
   The five raw keys above are still rendered by the company page and the detail view, which
   have room to name each route, so they stay. .vt-more is gone with the "+2 more" chip. */
.vt-sponsor{background:var(--visa-gc-bg);color:var(--visa-gc-text)}
.fnote{font-size:var(--text-2xs);line-height:1.45;color:var(--muted);margin:6px 0 0}
/* Update-jobs live progress bar */
.scrapebar{background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px;margin:0 0 14px;box-shadow:var(--e1)}
.sb-row{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-150);margin-bottom:8px;font-size:var(--text-sm)}
.sb-row #sb-label{font-weight:600;color:var(--ink)}
.sb-meta{color:var(--muted);font-size:var(--text-xs);font-variant-numeric:tabular-nums;text-align:right}
.sb-track{height:8px;border-radius:var(--radius-xs);background:var(--surface2);overflow:hidden}
.sb-fill{height:100%;width:0;border-radius:var(--radius-xs);background:var(--accent);transition:width .5s ease}
.sb-fill.indet{width:35%!important;background:linear-gradient(90deg,transparent,var(--brand),transparent);animation:sbslide 1.15s linear infinite}
@keyframes sbslide{0%{transform:translateX(-120%)}100%{transform:translateX(320%)}}
.ev{background:var(--visa-stem-bg);color:var(--visa-stem-text)}
.nospon{background:var(--visa-blocked-bg);color:var(--visa-blocked-text)}
/* THE STAR, and the legend that gives it a meaning.

   It replaced ", top sponsor to FY2025" inside the chip, which doubled the chip's width on 30%
   of cards to carry one bit. A mark can only mean something if it is explained somewhere the
   reader will actually pass, so .sponkey prints the sentence ONCE above the grid rather than
   relying on a tooltip nobody hovers.

   No colour of its own: it inherits the chip's, which is the point of putting it inside. The
   nudge is optical -- a star's ink sits low in its em box, so without it the mark reads as
   sitting below the text beside it. */
.topspon{font-size:1.02em;line-height:1;position:relative;top:.5px}
.sponkey{font-size:var(--text-2xs);color:var(--muted);margin:0 0 var(--space-150)}
.sponkey .topspon{color:var(--visa-h1b-text)}
.agency{background:var(--posting-agency-bg);color:var(--posting-agency-text)}
/* Work-authorization deadline strip. Only rendered when core.visa_alert finds something
   inside 90 days, so it must read as information, not as a permanent warning banner. */
.visabar{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-150);margin:0 0 12px;padding:9px 13px;border-radius:var(--r);font-size:var(--text-sm);border:1px solid}
.visabar .vb-when{font-weight:700}
.visabar .vb-note{color:var(--muted);font-size:var(--text-xs);flex:1;min-width:200px}
.visabar .vb-link{white-space:nowrap;font-weight:600;text-decoration:none;color:inherit;border-bottom:1px solid currentColor;opacity:.8}
.visabar-ok{background:var(--surface2);border-color:var(--line)}
.visabar-soon{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn)}
.visabar-urgent,.visabar-past{background:var(--danger-soft);border-color:var(--danger);color:var(--danger)}
/* Pay range pulled out of the JD, remote flag, and a closed/probably-filled posting. */
.pay,.rem{background:var(--visa-unknown-bg);color:var(--visa-unknown-text)}
.pay{font-variant-numeric:tabular-nums}
.closed{background:var(--visa-unknown-bg);color:var(--visa-unknown-text);border:1px solid var(--border-subtle)}
/* Posted N times. NEUTRAL, with a border to set it apart — the same treatment .closed gets,
   and for the same reason: in this design colour means SPONSORSHIP (see the route rail), so
   tinting a posting-quality chip would both overload that meaning and compete with the
   route colour on a coloured card. The text carries the signal; the count is in tabular
   figures so it reads as a measurement rather than as decoration. */
.repost{background:var(--visa-unknown-bg);color:var(--visa-unknown-text);
  border:1px solid var(--border-subtle);font-variant-numeric:tabular-nums}
/* "matched on description" -- same reasoning as .repost above, so it takes the same neutral
   treatment: this is a provenance note about HOW the job reached the feed, not a fact about the
   job, and colour in this design means sponsorship. A dashed border is the one distinction it
   gets, because the claim it makes is softer than the others on the row. */
.jdadmit{background:var(--visa-unknown-bg);color:var(--visa-unknown-text);
  border:1px dashed var(--border-subtle)}
/* A closed posting is de-emphasised rather than hidden when the user opts to see them. */
.card.is-closed{opacity:var(--opacity-deemphasis)}
.card.is-closed:hover{opacity:1}
/* Seniority is ONE continuous variable, so it reads as a value ramp rather than as three
   hues — and colour is spoken for by sponsorship anyway. These three classes previously
   resolved to the same grey, so the card computed a distinction it never drew. Tabular
   figures because "12+ yrs" and "2+ yrs" sit in the same column down a grid. */
.exp{font-family:var(--font-data);font-variant-numeric:tabular-nums;letter-spacing:0}
.exp-lo{background:var(--exp-entry-bg);color:var(--exp-entry-text)}
.exp-mid{background:var(--exp-mid-bg);color:var(--exp-mid-text)}
.exp-hi{background:var(--exp-senior-bg);color:var(--exp-senior-text)}
/* "years not stated" — the posting was NOT filtered by experience because its description
   carries no number. Deliberately the quietest badge on the card and set in the prose face, not
   the data face: it is an absence of a measurement, not a measurement. Muted rather than a
   status colour, because it is neither good nor bad news about the job. */
/* .exp-unknown is GONE. It styled a card badge fe62114 deleted, so for months the stylesheet
   carried a rule for a chip that could not render while the filter's help text still promised
   readers it was there. The card says "years not stated" again as of the experience-source
   work, as INK on the identity line — see .cexp above, which is a different element on purpose. */
/* Five controls, ~287px of card. They used to be forced onto one line, so "Mark applied"
   broke across two lines mid-phrase, every button grew to 44px tall, and the row overflowed
   its own card by 57px with Hide spilling past the edge.
   Wrapping instead: Apply and Tailor hold the first line, the three quiet triage actions
   drop to the second. Nothing is cut — which of these people actually use is a question for
   the analytics, and that data was only fixed on 2026-08-09. */
/* margin-top:auto PINS THIS TO THE BOTTOM, and that is what makes a row of cards look like a
   row rather than three separate things. Grid stretches every card in a line to the tallest, so
   without it a two-line title pushed its own Apply button 24px below its neighbours' and the
   buttons stepped down the page. */
.cardact{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-100);margin-top:auto;padding-top:13px;border-top:1px solid var(--line)}
.spacer{flex:1}
.acts{display:inline-flex;gap:var(--space-050);flex-wrap:wrap}
.ico{border:1px solid transparent;background:transparent;font-size:var(--text-xs);font-weight:500;color:var(--muted);cursor:pointer;padding:6px 11px;border-radius:var(--radius-full);white-space:nowrap;touch-action:manipulation;transition:background var(--t-fast) var(--ease),color var(--t-fast) var(--ease)}
.ico:hover{background:var(--surface2);color:var(--ink)}
.ico:active{opacity:.7}
.ico:disabled{opacity:.4}
.empty{background:var(--surface);border:1px dashed var(--line-strong);border-radius:var(--r-lg);padding:44px 26px;text-align:center;color:var(--muted);grid-column:1/-1}
.empty h3{color:var(--ink);margin:0 0 6px}
.empty .btn{margin-top:14px}
.empty.sm{padding:16px 18px;text-align:left;margin:8px 0}
.morewrap{text-align:center;margin:28px 0}

/* modal */
.modal-overlay{position:fixed;inset:0;background:rgba(10,12,18,.55);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);display:none;align-items:flex-start;justify-content:center;z-index:var(--z-modal);padding:40px 16px;overflow:auto;overscroll-behavior:contain}
.modal-overlay.open{display:flex}
.modal{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);max-width:760px;width:100%;box-shadow:var(--e3);animation:pop .22s ease}
@keyframes pop{from{opacity:0;transform:translateY(12px) scale(.98)}to{opacity:1;transform:none}}
.modal-head{display:flex;gap:var(--space-200);align-items:flex-start;padding:22px 24px 8px}
.modal-x{margin-left:auto;border:none;background:var(--surface2);width:32px;height:32px;border-radius:var(--radius-full);font-size:0;cursor:pointer;color:var(--muted);line-height:1;display:flex;align-items:center;justify-content:center;transition:background .15s,color .15s}
.modal-x::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E") center/contain no-repeat}
.modal-x:hover{background:var(--bg-active);color:var(--ink)}
.modal h2{margin:0;font-size:var(--text-md);letter-spacing:-.01em}
.modal-body{padding:6px 24px 4px}
.kw{display:flex;flex-wrap:wrap;gap:var(--space-100);margin:6px 0 14px}
/* The badge chips carry their own margin-left:6px so they space out inside the card's inline
   .cmeta run. Inside .kw's flex gap that becomes a double gap plus an indent on the first chip. */
.kw > *{margin-left:0}
/* HAVE vs MISS. "have" is a fact that went your way, so it takes the success pair. "miss"
   is the list headed "Add these to your résumé" — an opportunity you can act on, not an
   error — so it loses the red it used to carry and becomes an outlined ink chip, which
   reads as a to-do rather than as a fault. Red stays reserved for destructive controls. */
.tag{font-size:var(--text-xs);padding:4px 10px;border-radius:var(--radius-full);font-weight:500}
.tag.miss{background:transparent;color:var(--text-primary);box-shadow:inset 0 0 0 1px var(--border-strong)}
.tag.have{background:var(--status-success-bg);color:var(--status-success-text)}
/* The corpus norms on /job. Neither green nor red on purpose: "62% of Operations Manager
   postings say this" is a fact about the CORPUS, not a verdict on the reader, and giving it
   either of the keyword colours would read as one. .tag.miss is overridden to red inside
   .jobmain further down, so the norms need a class of their own. */
.tag.norm{background:transparent;color:var(--text-secondary);
  box-shadow:inset 0 0 0 1px var(--border-subtle)}
.tag.norm b{font-weight:600;color:var(--text-primary)}
.sechdr{font-size:var(--text-2xs);font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:16px 0 7px}
/* ---- /job SECTION HEADS ----
   .sechdr stays exactly as it is -- it is a 12px uppercase LABEL, it is right for the panels
   on /company and for the sidebar here, and ten call sites depend on it. What it cannot do is
   SEPARATE: at 12px muted with no rule and 16px of margin it reads as a caption on the
   paragraph beneath it, so nine sections ran together as one column of text and the page had
   no visible seams. /companies hit this first and solved it the same way (.cosec-h, ~line
   2980) rather than by making .sechdr louder for everybody.

   A rule ABOVE, not below: the boundary belongs at the start of a section, where the eye is
   looking for the next thing, and it is what turns a scroll into a sequence of rooms.

   NO GLYPH. These headings led with an emoji for one afternoon on 2026-09-03; removed at the
   owner's direction, along with the ones on the description's own headings below. The size, the
   ink and the rule are what do the separating, and they are enough. */
.jobsec{font-size:var(--text-md);font-weight:var(--weight-semibold);line-height:1.3;
  color:var(--ink);letter-spacing:var(--tracking-tight);
  margin:var(--space-500) 0 var(--space-200);padding-top:var(--space-250);
  border-top:1px solid var(--border-subtle)}
/* The first section on the page has the .jobrail panel directly above it, which is already a
   boundary. A second one 20px later reads as a double rule.
   :first-of-type, NOT :first-child -- .jobrail is .jobmain's first child, so :first-child
   matched nothing and the rule was silently dead. Every h2 in this column is a .jobsec, so
   "first h2 among the siblings" is exactly "the first section head". */
.jobmain > .jobsec:first-of-type{margin-top:var(--space-200);padding-top:0;border-top:0}
/* Job description. app.js jdHTML() emits real blocks (headings / <ul> / <p>) instead of one
   pre-wrapped blob, so it reads like a document rather than a paste.

   It keeps its own scroll container. Letting the whole modal grow instead was tried and is
   worse: these run to 7,000 characters, so the panel became several screens tall and the
   title, score and Apply button scrolled away above the text you were reading. Bounded here,
   the description scrolls under a header that stays put. overscroll-behavior stops a flick at
   the bottom of the JD from carrying on and scrolling the page behind the modal. */
/* NOT A BOX. No background, no border, no padding, and no max-height or overflow.
   All of that belonged to the modal, where the description shared a fixed-height panel with a
   header and a footer and needed an edge to sit inside. On a page it read as a quoted blob pasted
   in from somewhere else rather than as the page's own content, and the nested scroll container
   gave you two scrollbars. The description is the main thing here, so it is set as prose: type
   size and rhythm carry it, and the section heading above is the only boundary it needs. */
.jd{font-size:var(--text-base);line-height:1.7;color:var(--ink)}
.jd p{margin:0 0 var(--space-150)}
/* ONE RULE, and it used to be two. `.jd h4.jdh` was declared here at --text-2xs/18px and AGAIN
   ~20 lines down at --text-xs/22px, so what rendered was this rule's weight, colour and caps
   with THAT rule's size and margin -- and the comment here, which explained sizing these to
   match .sechdr, had described nothing for as long as the second rule existed. The surviving
   declaration is that effective cascade written out once. Same footgun CLAUDE.md flags for
   .cardverdict: a second rule further down the file wins silently and nothing looks broken.
   The live rationale for the size is below, where the second rule was. */
.jd h4.jdh{font-size:var(--text-xs);font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:var(--space-250) 0 var(--space-100)}
.jd ul{margin:0 0 var(--space-150);padding-left:var(--space-250)}
.jd li{margin:0 0 var(--space-050)}
.jd > :first-child{margin-top:0}
.jd > :last-child{margin-bottom:0}
/* The employer's own section labels, normalised by BUCKET and not by text: jdrender keeps their
   wording ("Basic Qualifications" is not the same promise as "Preferred Qualifications") and
   tags it, so the recognised kinds can be told apart without rewriting anybody's words. */
/* ---- THE CANONICAL SECTION HEAD, 2026-09-05 -------------------------------------------------
   OUR LABEL IS THE HEADING; THEIRS IS THE SUBTITLE. Every posting renders the same stack in the
   same order (jdrender SEC_ORDER), so .jdlabel is what the eye learns and what it can rely on
   being in the same place on the next job. The employer's own wording follows it in lighter ink
   because it still carries information our label cannot: "Basic Qualifications" and "Minimum
   Qualifications" both land in Required and are not the same promise.

   NOT UPPERCASE, unlike the bare .jdh above. These are section titles now rather than eyebrow
   labels on somebody else's prose, and at --text-xs uppercase they read as a caption on the
   paragraph under them -- the same failure .sechdr has, documented at the top of this block.

   ONE DECLARATION, and it is written out once on purpose. This rule and the border-left it
   carries were briefly two separate `.jd h4.jdh[data-sec]` blocks twenty lines apart, which is
   the footgun CLAUDE.md flags for .cardverdict and that this very file has already been bitten
   by twice (see the note above about .jdh being declared at two sizes). Same specificity, so the
   later one silently wins property by property and the earlier comment describes nothing. */
.jd h4.jdh[data-sec]{color:var(--muted);border-left:3px solid var(--border-strong);
  padding-left:9px;text-transform:none;font-size:var(--text-sm);letter-spacing:normal;
  display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-100)}
.jd .jdlabel{font-size:var(--text-md);font-weight:var(--weight-semibold);color:var(--ink);
  letter-spacing:var(--tracking-tight);text-transform:none}
/* AN ECHO IS HIDDEN, NOT DROPPED. When the employer's heading says what our label already says,
   rendering both stutters -- but deleting it in the renderer is text loss and the conservation
   check in scripts/test_jdrender.py fails on it. It stays in the DOM and goes away here, which
   also spares a screen reader the same word twice. */
.jd .jdecho{display:none}
/* A SECOND RUN IN THE SAME SECTION keeps its own label. An employer who wrote both "Basic
   Qualifications" and "Minimum Qualifications" gets both, under one Required heading. */
.jd h5.jdsub{font-size:var(--text-xs);font-weight:600;color:var(--muted);
  margin:var(--space-200) 0 var(--space-050);text-transform:none}
/* AN INFERRED SECTION IS MARKED, and this is the whole reason the flag exists. Nothing here
   said "Responsibilities" until 2026-09-05; on a description the employer never sectioned, that
   heading is OUR reading of their text, not their claim. A dotted rule rather than a solid one,
   so a reader can see which boundaries were drawn by us -- and so the tier stays auditable in
   the rendered corpus instead of being invisible once it ships. */
.jd h4.jdh[data-inferred]{border-left-style:dotted}
.jd h4.jdh[data-inferred] .jdlabel{color:var(--muted)}
/* The description's own blurb about the employer, under the page's About heading. Same prose
   rhythm as .jd, no section furniture: jdrender emits this half with no heading at all. */
.jdabout{margin-bottom:var(--space-200)}
/* ONE STATE LINE, where four sentences used to be. Terse and factual: it says which of the two
   opposite facts applies ("the employer did not say" versus "we have not read it"), and nothing
   about what the posting failed to contain. */
.jdstate{font-size:var(--text-sm);color:var(--muted);margin:0 0 var(--space-150)}
/* REQUIRED is the only section that gets the accent rule, and PREFERRED deliberately does not.
   They were one bucket until the six-bucket split, and the whole point of splitting them is
   that a reader can tell a must-have from a nice-to-have at a glance — giving both the same
   emphasis would put the distinction back where it was. Preferred keeps the neutral rule every
   other section has, which is what makes Required stand out. */
.jd h4.jdh[data-sec="req"]{border-left-color:var(--accent)}
/* The five buckets carried an anchor glyph here for one afternoon on 2026-09-03 and were
   removed the same day at the owner's direction. What still separates a description's sections
   is what always did: the employer's own heading text, the left rule, and the accent on `req`.
   THE EMPLOYER'S OWN WORDING IS NOT TOUCHED and that is the part to keep. "Basic Qualifications"
   and "Preferred Qualifications" both classify as requirements and mean different things;
   jdrender keeps both and test_job_page asserts it. */
/* WHY --text-xs AND NOT SMALLER (the rule itself is above, declared once). Unboxed, the JD's
   own headings have to carry more weight than the modal's 11px eyebrows did, or a
   4,000-character description reads as one undifferentiated run. */
/* ---- AT A GLANCE -----------------------------------------------------------------------------
   FIVE FACTS, FIVE TRACKS, AND THE SHAPE NEVER CHANGES. This replaced "What This Posting Asks
   For", a four-row definition list whose empty rows each carried a sentence explaining what the
   employer had not written. On a page where most rows are empty that is a paragraph of our voice
   standing in front of the employer's, and the owner's instruction on 2026-09-05 was a dash
   instead.

   auto-fit with a MINIMUM, not a fixed count: at 1440 it is five across, at 900 three, on a
   phone two. What has to hold is that Salary is always the first cell and Education always the
   last, so the same fact is in the same place on the next posting -- which is the entire reason
   the block exists.

   NO BOX. The .asks version had a border, a surface fill and a radius, which read as a widget
   bolted onto the page. These are the employer's facts stated plainly; a rule under the header
   is boundary enough. */
.jobglance{display:grid;grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr));
  gap:var(--space-200) var(--space-300);margin:0 0 var(--space-300);
  padding:0 0 var(--space-250);border-bottom:1px solid var(--border-subtle)}
.jobglance > div{min-width:0}
.jobglance dt{font-family:var(--font-data);font-size:var(--text-3xs);text-transform:uppercase;
  letter-spacing:var(--tracking-wide);color:var(--muted);margin-bottom:3px}
.jobglance dd{margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--ink);
  line-height:1.35;overflow-wrap:anywhere}
/* AN EMPTY CELL IS A DASH AND SAYS NOTHING ELSE. Muted so a row of them reads as absence rather
   than as content, and it must stay a glyph: this is the table saying "no value", not the prose
   dash CLAUDE.md's copy rules ban. */
.jobglance .nil{color:var(--border-strong);font-weight:400}
/* AN INFERENCE IS LABELLED AS ONE. The years read off a title are a 96%-accurate guess, and a
   guess printed in the same ink as a stated fact is worse than no answer at all. The sentence
   that used to follow it is a title= tooltip now -- tip.js promotes those for touch. */
.asks-guess{border-bottom:1px dotted var(--border-strong);cursor:help}
.jdjump{display:flex;flex-wrap:wrap;gap:var(--space-100);margin:0 0 var(--space-100)}
.jdjump a{font-size:var(--text-2xs);font-weight:600;text-transform:uppercase;letter-spacing:.05em;
  text-decoration:none;color:var(--muted);background:var(--surface2);border:1px solid var(--line);
  border-radius:var(--radius-full);padding:4px 11px}
.jdjump a:hover{color:var(--ink);border-color:var(--border-strong)}
/* EEO and legal notices, collapsed. <details> already has global styling, so this only needs to
   sit apart from the description above it. */
.jdlegal{margin-top:var(--space-150);border-top:1px solid var(--line);padding-top:var(--space-100)}
.jdlegal summary{font-size:var(--text-2xs);color:var(--muted);font-weight:600}
.jdlegal p,.jdlegal li{font-size:var(--text-2xs);color:var(--muted)}
/* MATCHED and MISSING keywords, inline in the description. GREEN and RED, on Kunal's call
   2026-08-11: the first pass used an ink tint and a dotted rule to keep the chromatic budget on
   sponsorship, and it was too quiet to scan. Green for what you already have, red for what would
   help — a legend at the top of the description says exactly that, so the two colours are defined
   on screen rather than assumed. They reuse the status tokens, which already carry this meaning
   everywhere else in the product and are already contrast-checked. */
mark.kw-have{background:var(--kw-have-bg);color:var(--kw-have-text);
  border-radius:3px;padding:0 3px;font-weight:600}
mark.kw-miss{background:var(--kw-miss-bg);color:var(--kw-miss-text);
  border-radius:3px;padding:0 3px;font-weight:600}
.kwlegend{font-size:var(--text-xs);color:var(--muted);margin:0 0 var(--space-150)}
/* The chip lists under the description use the SAME green and red as the marks in it, or the
   legend teaches one colour code and the lists below it use another. Scoped to .jobmain because
   .tag.miss is an ordinary outlined chip on the company page (its tech stack) and must stay one. */
.jobmain .kw .tag.miss{background:var(--kw-miss-bg);color:var(--kw-miss-text);box-shadow:none}

/* ---- THE JOB PAGE ------------------------------------------------------------------------
   FULL WIDTH, on Kunal's call 2026-08-11. It was --layout-page-max (1280px) with the reading
   column held at --layout-prose-max (68ch), which is the textbook measure for prose and left a
   1920px screen mostly empty either side — it read as a narrow strip of content in a lot of grey.
   It now uses the feed's own --layout-feed-max and lets the column take whatever the aside does
   not, so the page fills the screen the way every other page in the product does.

   The measure is the acknowledged trade: body text runs wider than 68ch on a large monitor. It is
   mitigated rather than ignored — 15px type with 1.7 line-height, and the JD's own headings and
   lists break the run up every few paragraphs, so it is a description with structure rather than
   an unbroken wall.

   DOM order equals the narrow-viewport order, which is why there is no `order` anywhere here:
   reordering with CSS would leave tab order following the source and reading order following the
   screen, and the two would disagree. */
.jobwrap{max-width:var(--layout-feed-max);margin:0 auto;padding:0 var(--space-300) var(--space-600)}
.jobhead{display:flex;gap:var(--space-200);align-items:flex-start;margin:0 0 var(--space-300)}
.jobhead h1{margin:0;line-height:1.15;font-size:var(--text-xl);letter-spacing:var(--tracking-tight)}
.jobident{color:var(--muted);font-size:var(--text-sm);margin-top:5px;display:flex;flex-wrap:wrap;
  align-items:center;gap:4px}
.jobident a{color:var(--ink);font-weight:600;text-decoration:none}
.jobident a:hover{text-decoration:underline}
/* Every date on this page is absolute and set in the data face. There is no relTime twin here on
   purpose: formatDates lives in app.js, which this page does not load. */
.jobdate{font-family:var(--font-data);font-variant-numeric:tabular-nums;font-size:var(--text-xs)}
.jobcols{display:grid;grid-template-columns:1fr;gap:var(--space-500)}
/* No max-width: the column takes the whole track. min-width:0 is load-bearing, or a long
   unbroken run inside the description sets the grid item's automatic minimum size and pushes the
   track past the container — the same min-content floor that made the feed scroll sideways. */
.jobmain{min-width:0}
.jobaside{min-width:0}
@media (min-width:1040px){
  .jobcols{grid-template-columns:minmax(0,1fr) 340px}
  /* THE ACTIONS STICK, NOT THE WHOLE RAIL. `.jobaside{position:sticky}` pins the TOP of the
     aside, so the moment its content is taller than the viewport -- which the rich role rows
     made routine -- Apply scrolls off the bottom and the sticky does nothing for the one thing
     the reader opened the page to press. Sticking the action block instead keeps Apply on screen
     and lets the lists scroll past it.
     Only where there is room: on a phone a sticky block would eat the viewport. */
  .jobaside-act{position:sticky;top:calc(var(--topbar-h) + var(--space-200));z-index:1;
    background:var(--surface);padding-bottom:var(--space-200)}
}
.jobaside-act{display:flex;flex-direction:column;gap:var(--space-100);margin-bottom:var(--space-200)}
/* The route band carries the card's own wash, so walking from the feed to this page does not
   change colour underneath the reader. The rest of the page stays a plain reading surface: a
   tint under 7,000 characters of description is a different proposition from a tint on a card. */
.jobrail{background:var(--route-h1b-fill);border:1px solid var(--route-h1b-line);
  border-radius:var(--radius-lg);padding:var(--space-200);margin:0 0 var(--space-300)}
.jobrail-facts{display:flex;flex-wrap:wrap;gap:var(--space-400);margin-bottom:var(--space-200)}
.jobrail .eyebrow{display:block;font-family:var(--font-data);font-size:var(--text-3xs);
  text-transform:uppercase;letter-spacing:var(--tracking-wide);color:var(--muted);margin-bottom:2px}
.jobroute b{font-size:var(--text-md);font-weight:var(--weight-semibold);color:var(--ink)}
.jobstat b{font-family:var(--font-data);font-size:var(--text-2xl);
  font-weight:var(--weight-semibold);font-variant-numeric:tabular-nums;
  letter-spacing:var(--tracking-tight);line-height:1.1;display:block}
/* Same three values the card's arc uses, so a 72% reads the same colour in both places. */
.jobstat b.meter-strong{color:var(--match-strong)}
.jobstat b.meter-good{color:var(--match-good)}
.jobstat b.meter-weak{color:var(--match-weak)}
.jobnote{font-size:var(--text-xs);color:var(--muted);margin:2px 0 0;max-width:34ch;line-height:1.45}
.jobacts{display:flex;flex-wrap:wrap;gap:var(--space-100);align-items:center}
.jobact-form{display:flex;flex-wrap:wrap;gap:var(--space-100);margin:0}
.jobchips{margin:var(--space-150) 0 0}
/* Neutral chips on a tinted band, the same treatment cards get. */
.jobrail .pay,.jobrail .rem,.jobrail .exp,.jobrail .intl,.jobrail .agency,.jobrail .closed,.jobrail .repost,.jobrail .jdadmit,
.jobrail .cx{background:var(--on-card-veil)}
.jobrail .btn:not(.primary){background:transparent}
.jobrail .btn:not(.primary):hover{background:var(--on-card-veil)}
/* The five-route table with a tip per route lived here. It answered "what is an H-1B", which is
   not what somebody who opened a job description came to find out, and it pushed the description
   itself below the fold. Sponsorship is two sentences at the foot of the page now, and /company
   keeps the per-route detail and the year by year chart. */

/* OTHER ROLES AT THIS EMPLOYER, in the rail. A sideways jump, so it belongs beside the posting
   rather than inside it. The 3px edge is the accent at full strength rather than the pale line,
   because at this size a wash would not read at all — but it is one colour now, like the cards:
   the stripe marks the row, it no longer classifies it. */
/* A ROW, NOT A LINK. These were a bare title over a 10px meta line with a coloured left edge,
   and the owner's report was that they did not look like anything: "it doesn't look appealing,
   it's not effective at all". A row now carries the employer's logo, the title, and the facts
   worth choosing on -- place, remote, pay, match -- in a three-track grid so the score column
   lines up down the list the way the feed's fact cells do.

   THE LEFT RULE IS GONE. It marked the row and classified nothing (one hue, since 2026-09-01),
   so with a logo tile beside it it was a second vertical edge doing no work. */
.simlist{list-style:none;margin:0 0 var(--space-200);padding:0}
.simrow{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:var(--space-150);
  align-items:start;padding:var(--space-150) 0;border-bottom:1px solid var(--border-subtle)}
.simrow:last-child{border-bottom:0}
.simlogo{width:28px;height:28px;object-fit:contain;border-radius:var(--radius-sm);
  background:var(--surface2);flex:0 0 auto}
/* The initials tile, for the employers we have no logo for. Same box, so the title column starts
   at the same offset on every row whether or not the logo exists. */
.simmono{display:flex;align-items:center;justify-content:center;font-family:var(--font-data);
  font-size:var(--text-3xs);font-weight:700;color:var(--muted)}
/* min-width:0 is load-bearing on BOTH: without it a long unbroken title sets the grid item's
   automatic minimum and pushes the score column out of the 340px rail. */
.simbody{min-width:0}
.simtitle{display:block;font-size:var(--text-xs);font-weight:600;color:var(--ink);
  text-decoration:none;line-height:1.35}
.simtitle:hover{text-decoration:underline}
.simmeta{display:flex;flex-wrap:wrap;gap:2px var(--space-100);align-items:baseline;margin-top:3px;
  font-size:var(--text-3xs);color:var(--muted)}
.simscore{font-family:var(--font-data);font-variant-numeric:tabular-nums;
  font-size:var(--text-2xs);font-weight:var(--weight-semibold)}
/* The metadata header some boards stack one line at a time — Clearance Level / None / Category /
   Software Engineering — which used to render as a ladder of orphaned paragraphs. Two explicit
   columns rather than auto-placement, so a label with SEVERAL values (Key Skills For Success) keeps
   all of them in the value column instead of letting the second one fall under the label. */
.jdkv{display:grid;grid-template-columns:minmax(6.5rem,max-content) 1fr;
  gap:2px var(--space-200);margin:0 0 var(--space-300);align-items:baseline}
.jdkv dt{grid-column:1;font-size:var(--text-3xs);letter-spacing:.06em;text-transform:uppercase;
  color:var(--muted);font-weight:var(--weight-semibold);padding-top:2px}
.jdkv dd{grid-column:2;margin:0;font-size:var(--text-xs);color:var(--ink);line-height:1.5}
/* A field's second and later values sit tight under the first so the group reads as one answer. */
.jdkv dd + dd{padding-top:1px}
@media (max-width:640px){
  .jdkv{grid-template-columns:1fr;gap:0 0}
  .jdkv dt{grid-column:1;margin-top:var(--space-150)}
  .jdkv dd{grid-column:1}
}

/* Employer name in the "Similar Roles" rail. It is the one field that varies down that list and
   the reason to click a row, so it reads darker than the rest of the meta line and is allowed to
   take the space, with the numbers and location holding their own width beside it. */
.simco{color:var(--ink);font-weight:var(--weight-medium);overflow:hidden;
  text-overflow:ellipsis;white-space:nowrap;max-width:11em;flex:0 1 auto}
.simscore.meter-strong{color:var(--match-strong)}
.simscore.meter-good{color:var(--match-good)}
.simscore.meter-weak{color:var(--match-weak)}
.jobaside-apply{width:100%;justify-content:center;margin-bottom:var(--space-300)}
/* Buttons on the job page embed the EMPLOYER NAME — "Tailor for General Dynamics Information
   Technology", "All 6 Roles at General Dynamics Information Technology" — and the base .btn is
   white-space:nowrap. A long name therefore sets a min-content floor wider than a phone, which
   pushed the whole PAGE 34px sideways at 390px and 49px at 375px. Completely invisible in a
   screenshot; found by measuring scrollWidth - innerWidth across widths. Scoped to .jobwrap so
   the feed's inline chip rows keep their single-line shape. */
.jobwrap .btn{white-space:normal;text-align:center}
.jobgone-url{font-family:var(--font-mono);font-size:var(--text-xs);word-break:break-all;
  color:var(--muted)}
.modal-foot{display:flex;gap:var(--space-100);align-items:center;padding:14px 24px 20px;border-top:1px solid var(--line);margin-top:12px;position:sticky;bottom:0;background:var(--surface)}

/* toasts */
.toasts{position:fixed;right:max(18px,env(safe-area-inset-right));bottom:max(18px,env(safe-area-inset-bottom));display:flex;flex-direction:column;gap:var(--space-100);z-index:var(--z-toast)}
.toast{background:var(--ink);color:var(--bg);padding:11px 15px;border-radius:var(--r);font-size:var(--text-sm);font-weight:500;box-shadow:var(--e2);animation:slidein .2s ease}
@keyframes slidein{from{opacity:0;transform:translateX(20px)}to{opacity:1;transform:none}}
/* The ASKING toast (applyask.js): "did you apply to X?" after an Apply click. Wider and taller
   than a notification because it holds a job title and two real buttons, and it does not fade,
   so it must be readable at rest rather than glanceable.
   max-width, not width: a one-word company should not draw a 380px box. */
.toast-ask{display:flex;flex-direction:column;gap:var(--space-150);max-width:min(380px,88vw);
  padding:14px 16px;align-items:stretch}
.toast-ask .ask-q{font-weight:var(--weight-semibold);line-height:1.4}
.toast-ask .ask-btns{display:flex;gap:var(--space-100);justify-content:flex-end}
/* Both buttons are stated against the TOAST, which inverts the page: its background is --ink
   and its text is --bg in both themes. So the affirmative is filled with the page's own colours
   -- 17.98:1 in light, 16.48:1 in dark -- and the negative borrows the toast's own text colour,
   which is the same contrast the question above it already has. Neither needs a new token. */
.toast-ask .ask-yes,.toast-ask .ask-no{font:inherit;font-size:var(--text-xs);font-weight:700;
  padding:7px 13px;border-radius:var(--radius-sm);cursor:pointer;white-space:nowrap;
  transition:background var(--t-fast) var(--ease),border-color var(--t-fast) var(--ease)}
.toast-ask .ask-yes{background:var(--bg);color:var(--ink);border:1px solid var(--bg)}
.toast-ask .ask-yes:hover{background:var(--bg-hover)}
.toast-ask .ask-no{background:transparent;color:var(--bg);border:1px solid currentColor;
  opacity:.75}
.toast-ask .ask-no:hover{opacity:1}
.toast-ask .ask-yes:focus-visible,.toast-ask .ask-no:focus-visible{outline:2px solid var(--bg);
  outline-offset:2px}

/* careers / addboard / misc */
.careers h2{font-size:var(--text-md);margin:22px 0 6px}.careers h3{font-size:var(--text-base);margin:12px 0 4px}
.careers li{list-style:none;margin:3px 0}
.addform{display:flex;flex-direction:column;gap:var(--space-150);max-width:560px}
.result{padding:11px 14px;border-radius:var(--r);margin:12px 0}
.result.ok{background:var(--status-success-bg);border:1px solid var(--status-success-bg);color:var(--status-success-text)}
.result.err{background:var(--danger-soft);border:1px solid var(--danger);color:var(--danger)}
.result.info,.result.sql{background:var(--blue-soft);border:1px solid var(--line);color:var(--blue)}
pre{background:#11131a;color:#cdd3e1;padding:12px;border-radius:var(--r);overflow:auto;font-size:var(--text-2xs)}
/* A board row carries a raw careers URL, which has no spaces to break at, so on a phone it
   pushed the page 502px wide. overflow-wrap:anywhere lets it break mid-string, min-width:0
   lets the flex child actually shrink to allow that, and the row wraps rather than squeezing
   the Remove button to nothing. */
.brow{display:flex;align-items:center;gap:var(--space-150);justify-content:space-between;flex-wrap:wrap;border:1px solid var(--line);border-radius:var(--r);padding:13px 15px;margin:8px 0;background:var(--surface)}
.brow > div{flex:1 1 240px;min-width:0}
.brow .muted{overflow-wrap:anywhere}
.back{font-size:var(--text-xs);display:inline-block;margin-bottom:8px;font-weight:500}

/* loading */
@keyframes spin{to{transform:rotate(360deg)}}
.spin{display:inline-block;width:16px;height:16px;border:2.5px solid var(--line);border-top-color:var(--brand);border-radius:var(--radius-full);animation:spin .7s linear infinite;vertical-align:middle;flex-shrink:0}
@keyframes shimmer{0%{background-position:-600px 0}100%{background-position:600px 0}}
/* .shim / .shim-chip / .shim-tag / .shim-bar / .shim-row lived here: the job modal's loading
   shimmer, whose only caller was openModal(). The modal is gone and /job is server-rendered,
   so there is nothing left to shim. @keyframes shimmer above STAYS: the feed's own .skel
   loading card uses it. (Those two used to share names, which is why .shim was renamed.) */

/* score ring */
/* THE MATCH METER. Green at 70 and up, amber 40 to 69, red below 40 — the thresholds live in
   scoreRing() in app.js and the colours live here, so neither can be changed by accident from
   the other side. The class names are magnitude, not hue, so re-tuning a colour never means
   renaming a class. */
.ring-strong{--ring-color:var(--match-strong);--ring-track:var(--match-track)}
.ring-good{--ring-color:var(--match-good);--ring-track:var(--match-track)}
.ring-low{--ring-color:var(--match-weak);--ring-track:var(--match-track)}
.score-ring{flex-shrink:0;display:block}
/* A CIRCLE, because everything else in this slot is one. It was radius-md -- a rounded square
   sitting between .score-ring (an SVG circle) and .score-none (a dashed circle), at the same
   46px, in the same corner. Three states, two shapes, and the odd one out was the one that
   appears most often. Reported from the live feed 2026-09-02 as looking like a different kind
   of element, which is exactly what it was.

   The labels stay INSIDE the circle rather than moving to the chip row: "don't add a second
   chip to the card" is a decision this project has already walked back twice, and the whole
   point of this slot is that one glance at the top-right corner answers "how well does this
   match me". An empty circle would have matched .score-none more cleanly, but it would make
   "not read yet" and "can never be read" identical -- a distinction that cost real work to
   establish -- and a bare glyph in this circle has already been tried and read as a rendering
   artefact.

   font-size stays on the type scale at --text-3xs. That is what forced "No description" to
   become "No JD" in app.js: "description" is ~62px at 11px type and the circle's usable chord
   is ~40px, so the honest choices were a shorter word or a font size the rest of the product
   never uses. The tooltip still carries the full sentence. */
.score-pending{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:46px;height:46px;padding:0 3px;border-radius:var(--radius-full);font-size:var(--text-3xs);font-weight:700;line-height:1.1;text-align:center;color:var(--muted);background:var(--surface2);border:1px dashed var(--warn-line)}
/* No résumé: the card holds the ring's space but shows no number, so the layout doesn't shift
   once scoring starts and nothing on the card claims a match we cannot compute. Deliberately
   quiet — this is an absence, not a warning, and an empty dashed ring says that on its own
   without a character typed into the middle of it. */
.score-none{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;
  width:46px;height:46px;border-radius:var(--radius-full);color:var(--muted);
  background:transparent;border:1px dashed var(--line-strong);cursor:help}
.loading-jd{display:flex;align-items:center;gap:var(--space-150);color:var(--muted);font-size:var(--text-sm);padding:8px 4px}

/* page progress */
.page-progress{position:fixed;top:0;left:0;height:2px;background:var(--accent);z-index:var(--z-progress);pointer-events:none;width:0;opacity:0;border-radius:0 var(--radius-full) var(--radius-full) 0}

/* --- BETWEEN PAGES ----------------------------------------------------------------------
   A cross-fade on same-origin navigations. Two lines of CSS, no JavaScript and no framework:
   the browser snapshots the old page, loads the new one and fades between them. Browsers
   without support ignore the at-rule entirely and navigate exactly as they do today, so there
   is nothing to feature-detect and nothing to fall back to.

   It complements #pgbar rather than replacing it -- the bar says "something is happening" for
   a slow route, this smooths the swap once the bytes arrive.

   150ms on purpose. The default is 250ms, which on a fast internal link reads as the app
   being sluggish rather than as polish. The global reduced-motion override at the top of this
   file cannot reach these: it selects *, *::before and *::after, and none of those match a
   ::view-transition pseudo-element. So they are silenced explicitly. */
@view-transition{navigation:auto}
::view-transition-old(root),::view-transition-new(root){animation-duration:.15s;animation-timing-function:var(--ease-out)}
@media (prefers-reduced-motion:reduce){
  ::view-transition-old(root),::view-transition-new(root){animation:none}
}

/* ============ Resume Brain ============ */
.rb{max-width:920px;margin:0 auto}
.rb h1{font-size:var(--text-xl);margin:0 0 4px}
.rb h2{font-size:var(--text-md);margin:0}
/* flex-direction:column for the reason .cocard carries it: .card's base rule is the
   FEED card's layout and these are document cards. Above the feed's 1760 breakpoint
   an <h2> and its paragraph sat SIDE BY SIDE with the heading crushed to its own
   text width (measured 77px against 870). Column, not block: flex margins do not
   collapse, and these children are spaced by their own margins. */
.rb .card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);padding:22px 24px;margin:0 0 16px;flex-direction:column}
.rb .card.ai{border-left:3px solid var(--accent)}
.rb .muted{color:var(--muted)}
.rb .small{font-size:var(--text-xs)}
/* .dzcap is the caption above a .dropzone, which is itself a <label> -- so it cannot BE a label
   without nesting one inside another and making the click target ambiguous. It shares this rule
   rather than getting its own copy, because the whole point is that it looks like the labels
   above and below it. */
.rb label,.rb .dzcap{display:block;margin:12px 0;font-weight:600;font-size:var(--text-sm)}
.rb label .muted{font-weight:400}
.rb .req{color:var(--danger)}
.rb .grid2{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-200)}
.rb .row{display:flex;gap:var(--space-150);align-items:center;flex-wrap:wrap;margin-top:12px}
.rb .row.between{justify-content:space-between}
.rb .row.between h2{margin:0}
.rb textarea,.rb input[type=text],.rb input[type=password]{width:100%;box-sizing:border-box}
.rb textarea.mono{font-family:var(--font-mono);font-size:var(--text-xs);line-height:1.5}
.rb .chips{display:flex;flex-wrap:wrap;gap:var(--space-100);margin:8px 0}
.rb .chip{display:inline-block;padding:5px 11px;border-radius:var(--radius-sm);font-size:var(--text-xs);font-weight:500;background:var(--surface2);color:var(--ink)}
.rb .chip.miss{background:transparent;color:var(--text-primary);box-shadow:inset 0 0 0 1px var(--border-strong)}
.rb .chip.tone{background:var(--brand-soft);color:var(--brand-ink)}
.rb .cov{display:flex;align-items:center;gap:var(--space-250);margin:12px 0;background:var(--surface2);border:1px solid var(--line);border-radius:var(--r);padding:14px 18px}
.rb .cov-num{display:flex;flex-direction:column}
.rb .cov .big{font-family:var(--font-data);font-size:var(--text-2xl);font-weight:var(--weight-semibold);font-variant-numeric:tabular-nums;letter-spacing:var(--tracking-tight);line-height:1}
.rb .cov .big.up{color:var(--status-success-text)}
.rb .cov-arrow{display:flex;align-items:center;color:var(--text-tertiary)}
.rb .cov-arrow .ic{width:20px;height:20px}
.rb .coach{list-style:none;padding:0;margin:0}
.rb .coach li{padding:11px 0;border-bottom:1px solid var(--line)}
.rb .coach li:last-child{border-bottom:0}
.rb .storypick{display:flex;align-items:center;gap:var(--space-100);margin:0;font-weight:500}
.rb .storypick input{width:auto;margin:0;accent-color:var(--brand)}
.rb fieldset.tighten{border:1px solid var(--line);border-radius:var(--r);padding:10px 13px;margin:12px 0;display:grid;gap:var(--space-100)}
.rb fieldset.tighten legend{padding:0 6px}
.rb .badge{display:inline-block;background:var(--surface2);border:1px solid var(--line);border-radius:var(--radius-xs);padding:1px 7px;font-size:var(--text-3xs);color:var(--muted);font-weight:600}
.rb .note{background:var(--brand-soft);border:1px solid var(--line);border-radius:var(--r);padding:10px 13px;margin:8px 0;font-size:var(--text-xs);color:var(--ink)}
.rb .note.warn{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn)}
.rb .lesson{display:flex;justify-content:space-between;gap:var(--space-150);align-items:center;padding:11px 0;border-bottom:1px solid var(--line)}
.rb .lesson:last-child{border-bottom:0}
.rb .inline{display:inline}
.rb .jobpick{margin:8px 0}
.rb .jobpick a{display:block;padding:9px 12px;border:1px solid var(--line);border-radius:var(--radius-sm);margin:5px 0;background:var(--surface2);font-weight:500}
.rb .jobpick a:hover{border-color:var(--line-strong)}
.rb .kb-item{border:1px solid var(--line);border-radius:var(--radius-md);padding:10px 13px;margin:8px 0;background:var(--surface)}
.rb summary.addnew{color:var(--brand);font-weight:600}

/* ---- admin dashboard ---- */
.adm-head{margin:0 0 16px}
.adm-nav{display:flex;gap:var(--space-025);margin:0 0 18px;border-bottom:1px solid var(--line);flex-wrap:wrap}
.adm-nav a{padding:9px 15px;font-size:var(--text-sm);font-weight:600;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px}
.adm-nav a:hover{color:var(--ink)}
.adm-nav a.on{color:var(--ink);border-bottom-color:var(--accent)}
/* auto-fit + minmax rather than a fixed column count: the tile row reflows from 5 across on a
   desktop down to 1 on a phone without a single media query, and adding a sixth tile later
   needs no CSS change. */
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:var(--space-150);margin:0 0 18px}
.tile{background:var(--surface);border:1px solid var(--border-card);border-radius:var(--r-lg);padding:16px 18px;display:flex;flex-direction:column;gap:var(--space-050)}
.tile.warn{border-color:var(--warn-line);background:var(--warn-bg)}
.tile-n{font-family:var(--font-data);font-size:var(--text-xl);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);line-height:1.15;font-variant-numeric:tabular-nums}
.tile-l{font-size:var(--text-sm);font-weight:600;color:var(--ink)}
.tile-s{font-size:var(--text-xs);color:var(--muted)}
.adm-cols{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-250);align-items:start}
.adm-cols>.panel{margin:0 0 18px}
.panel h2{font-size:var(--text-base);margin:0 0 4px}
.panel h2+.muted{margin:0 0 14px}
.adm-t{width:100%;border-collapse:collapse;font-size:var(--text-sm)}
.adm-t th{text-align:left;font-size:var(--text-2xs);text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600;padding:0 8px 7px 0;border-bottom:1px solid var(--line)}
.adm-t td{padding:8px 8px 8px 0;border-bottom:1px solid var(--line);vertical-align:top}
.adm-t tr:last-child td{border-bottom:0}
/* A list whose HEIGHT is bounded, so the page costs the same for 3 users as for 300 -- the rows
   scroll inside the panel instead of pushing everything under them off the screen. 60vh, not a
   pixel value: on a laptop that is roughly a dozen rows and still leaves the panels below it
   visible, and on a tall monitor it grows rather than wasting the space.
   The sticky header is what makes scrolling readable -- a scrolled table with its column
   labels gone is a wall of numbers. */
.adm-scroll{max-height:60vh;overflow-y:auto;overscroll-behavior:contain}
.adm-scroll .adm-t th{position:sticky;top:0;z-index:1;background:var(--surface);
  /* The sticky row sits ON the scrolled content, so its bottom rule has to be a shadow: a
     border would scroll with the cell box and let the first data row bleed through. */
  box-shadow:inset 0 -1px 0 var(--line)}
/* The last row keeps its rule here: inside a scroll box it is the seam that shows there is
   more, and dropping it (as .adm-t does) makes a full list look like it ends early. */
.adm-scroll .adm-t tr:last-child td{border-bottom:1px solid var(--line)}
.adm-scroll .adm-t td a{font-weight:600}
.adm-t .num{text-align:right;font-family:var(--font-data);font-variant-numeric:tabular-nums}
.adm-t code{font-size:var(--text-xs);background:var(--surface2);padding:1px 6px;border-radius:var(--radius-xs)}
.pill{display:inline-block;font-size:var(--text-2xs);font-weight:700;padding:2px 9px;border-radius:var(--radius-md);text-transform:uppercase;letter-spacing:.03em}
/* A status pill has to carry its status in the colour, so these keep the status tokens.
   .ok used to borrow the brand tint, which now resolves to a neutral and would have left
   "ok" and "unknown" looking identical. */
.pill.ok{background:var(--status-success-bg);color:var(--status-success-text)}
.pill.bad{background:var(--danger-soft);color:var(--danger)}
/* --warn-ink was never a real token, so this silently fell back to --ink and the running
   pill has always rendered primary-black text on an amber field. --status-warn-text is the
   contrast-checked pair for --status-warn-bg. */
.pill.run{background:var(--status-warn-bg);color:var(--status-warn-text)}
.hist{display:flex;flex-direction:column;gap:var(--space-100)}
.hist-row{display:grid;grid-template-columns:56px 1fr 56px;align-items:center;gap:var(--space-150);font-size:var(--text-xs)}
.hist-k{color:var(--muted);font-family:var(--font-data);font-variant-numeric:tabular-nums}
.hist-bar{background:var(--surface2);border-radius:var(--radius-xs);height:16px;overflow:hidden}
/* min-width so a non-zero bucket is never invisible next to a much larger one — a bar of 0px
   reads as "no jobs here", which is a different fact from "few jobs here". */
.hist-bar i{display:block;height:100%;min-width:3px;border-radius:var(--radius-xs);background:var(--accent)}
.hist-v{text-align:right;font-family:var(--font-data);font-variant-numeric:tabular-nums;font-weight:var(--weight-semibold)}
.sqlblock{background:var(--surface2);border:1px solid var(--line);border-radius:var(--radius-md);padding:14px 16px;font-size:var(--text-xs);line-height:1.5;overflow-x:auto;white-space:pre;margin:0;font-family:var(--font-mono)}
.tile .hist-bar{display:block;width:100%}
/* Supply-vs-demand: two bars per bucket on one row. Grey is what the scraper collected, colour
   is what someone actually wanted, so the eye compares them without reading the numbers. */
.hist-row.sd{grid-template-columns:56px 1fr 42px 1fr 42px}
.hist-bar i.sup{background:var(--line-strong)}
.panel h3{color:var(--ink)}
/* account rows: the per-user buttons wrap instead of forcing the table wider */
.userops{display:flex;gap:var(--space-100);flex-wrap:wrap;align-items:center}
.userops form{margin:0}
.pwreset{display:inline-block}
.pwreset summary{list-style:none;display:inline-flex}
.pwreset summary::-webkit-details-marker{display:none}
/* NOTE the explicit width:auto in each of these. The base rule at the top of this file gives
   every input width:100%, which inside a flex row puts each control on its own line — the
   create form rendered three rows tall before this. Anything laying inputs out horizontally
   has to opt back out. */
.pwform{display:flex;gap:var(--space-100);margin-top:6px}
.pwform input{width:auto;flex:1 1 150px;padding:6px 10px;border-radius:var(--radius-sm);font-size:var(--text-xs)}
.newuser{display:flex;gap:var(--space-100);flex-wrap:wrap;align-items:center}
.newuser input{width:auto;flex:1 1 200px;max-width:280px;padding:8px 12px;font-size:var(--text-sm)}
.confirmform label{display:block;font-size:var(--text-sm);margin-bottom:8px}
.confirmform input{width:100%;max-width:360px;padding:9px 12px;font-size:var(--text-sm)}
.confirmrow{display:flex;gap:var(--space-100);margin-top:14px}
.delform{display:flex;gap:var(--space-200);align-items:flex-end;flex-wrap:wrap}
.delform label{display:flex;flex-direction:column;gap:var(--space-100);font-size:var(--text-xs);color:var(--muted);flex:1 1 260px}
.delform input,.delform textarea{width:100%;font-size:var(--text-sm);padding:8px 12px}
.delform textarea{min-height:44px}
.blockrow{display:flex;gap:var(--space-100);align-items:baseline;padding:5px 0;font-size:var(--text-sm);cursor:pointer}
.blockrow input{width:auto;flex:0 0 auto;margin:0}
.btn.danger:hover{background:var(--danger-soft)}

/* responsive */
@media (max-width:900px){
  .rb .grid2{grid-template-columns:1fr}
  .adm-cols{grid-template-columns:1fr;gap:0}
  /* Five columns (label + two bars + two numbers) don't fit a phone: the fixed tracks eat the
     row and both 1fr bars collapse to 0px, which renders as two invisible bars rather than a
     comparison. Stack supply over demand instead — the numbers still read side by side. */
  .hist-row.sd{grid-template-columns:56px 1fr 42px;row-gap:var(--space-050)}
  .hist-row.sd .hist-bar{grid-column:2}
  .hist-row.sd .hist-k{grid-row:span 2;align-self:center}
  /* Wide tables (per-table sizes, per-user actions) scroll rather than squeeze to nothing. */
  .panel{overflow-x:auto}
}
/* THE COLUMN COUNT STOPPED DEPENDING ON THE VIEWPORT BEING GUESSED RIGHT.
   Two attempts to place a three-column floor by viewport both missed the machine this is built
   for -- 1600 excluded it, then 1400 excluded it -- because the width was inferred from a
   screenshot instead of measured, and a breakpoint you cannot measure is the wrong mechanism.
   What actually made three columns expensive was the PANEL: a fixed 156px side column out of
   every card, so the white half was card-width minus 156 and a laptop had ~280px left for a
   title. So the panel gives way first (below 1500 it becomes a strip inside the card and the
   body gets the full width back), and only then does the grid drop a column. Three columns now
   cost a card ~366px instead of ~440, which is why the floor can sit at 1080.

   1760 IS SOLVED, NOT PICKED. The side column costs 156px of body, so it is worth having only
   while the body still clears ~400px: body = (vw - 84)/3 - 158 >= 400 gives vw >= 1758.
   Measured, the switch is stark -- at 1600 with the side column 36 of 60 titles clamped; at
   1500 with the strip, 0 did, on a card 33px NARROWER. The panel was the whole cost. */
@media (max-width:1760px){
  /* THE PANEL BECOMES A STRIP, and this is the whole fix. Ring, label and the sponsorship line
     on one row across the top of the card; .cardbody then has the card's full width for the
     title, which is what a laptop was short of. Above this the side column is better -- it
     reads as a column of verdicts down the page -- so it stays there. */
  .card{flex-direction:column}
  .cardbody{padding:14px 18px 16px}
  /* order:-1 puts the strip ABOVE the body. Source order sends it to the bottom, which left the
     card ending on a heavy block with Apply and Hide stranded above it -- the actions have to be
     the last thing. On a wide screen the panel is on the trailing edge and that is right;
     stacked, "trailing" would put it after the buttons. */
  .cardverdict{order:-1;flex:0 0 auto;width:100%;flex-direction:row;justify-content:flex-start;
    align-items:center;gap:var(--space-150);padding:8px 18px;text-align:left;
    border-left:0;border-bottom:1px solid var(--verdict-line)}
  /* 44px, not 72. In a strip the ring sits beside its own label rather than above it, so it is
     one line tall and does not need to anchor a column. */
  .cardverdict .score-ring{width:44px;height:44px}
  .cardverdict .score-pending,.cardverdict .score-none{width:44px;height:44px}
  .cvlabel{letter-spacing:var(--tracking-normal)}
  .cbadges{width:auto;margin:0 0 0 auto;padding:0;border-top:0;align-items:flex-end}
}
@media (max-width:1080px){
  .feedgrid{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media (max-width:780px){
  .feedgrid{grid-template-columns:1fr}
}
@media (max-width:700px){
  /* 176 is 51% of a 345px body on a phone. The cap comes down with the card. */
  .cmark{max-width:140px}
  .cardbody{padding:16px}
  .cardverdict{padding:10px 16px}
  /* FIXED TRACKS EARN NOTHING HERE, so they go. Their whole purpose is comparing a cell against
     the same cell on a neighbouring card, and at this width there is only ever one card on a
     line -- what the grid buys instead is two empty cells holding their gaps open, so
     "O'Fallon, Missouri" and "senior role" sit in opposite corners with nothing between them.
     Wrapped inline, the facts read as one sentence and the blanks cost nothing. */
  .cfacts{display:flex;flex-wrap:wrap;gap:var(--space-050) var(--space-200)}
  .cfact-blank{display:none}
}
@media (max-width:560px){
  main{padding:18px 14px 60px}
  .topbar{gap:var(--space-150)}.topbar nav{order:3;width:100%}
  .section-head{flex-wrap:wrap}
  .cta-banner .btn{width:100%}
  .modal{border-radius:var(--r-lg) var(--r-lg) 0 0}
  .modal-foot{flex-wrap:wrap}
  h1,.rb h1{font-size:var(--text-lg)}
  .countrow{flex-wrap:wrap}
  .countrow .sel{width:100%}
  .tab{padding:8px 13px}
  .roletiles{grid-template-columns:1fr}
  .modal .rolegroups{max-height:58vh}
}

/* ---- role picker ------------------------------------------------------------------
   "What kind of job do you want", which until now had two possible answers. Shared by the
   feed's modal and onboarding step 3 via _rolepicker.html, so both get this styling and
   neither can drift from the other. Vars only — it inherits dark mode for free. */
/* ---- ROLE PICKER --------------------------------------------------------------------
   Rebuilt 2026-08-10. It was 22 bordered BOXES two across, each holding a native checkbox,
   a label and a count stacked vertically. That is 960px of content in a 520px window, so
   two of the four groups were always below the fold, and a role whose name wrapped made its
   row taller than its partner, which left the grid visibly ragged.

   One line per role instead: name on the left, count right-aligned in the data face. The
   counts become a column you can scan, which matters because "how many jobs are actually
   open" is the only comparative information on this screen. Twenty-two roles now fit in
   roughly the height that six used to take. */
.rolepick{display:flex;flex-direction:column;gap:var(--space-250);min-width:0}
.rolesearch{display:flex;gap:var(--space-150);align-items:center}
.rolesearch input{flex:1;padding:11px 14px;border:1px solid var(--line-strong);
  border-radius:var(--r);background:var(--surface);color:var(--ink);font-size:var(--text-sm);font-family:inherit}
.rolesearch input:focus{outline:none;box-shadow:var(--focus);border-color:var(--accent)}
/* A state, not a caption: it is the only thing saying the selection is capped. */
.rolecount{flex:0 0 auto;font-family:var(--font-data);font-size:var(--text-2xs);
  font-variant-numeric:tabular-nums;color:var(--text-secondary);white-space:nowrap;
  background:var(--bg-sunken);border:1px solid var(--border-subtle);
  border-radius:var(--radius-full);padding:var(--space-050) var(--space-150)}
.rolecount b{color:var(--ink)}
/* HORIZONTAL. The groups were stacked, which is what forced a scroller: four sections of
   rows in a 560px box could only ever show two. Side by side they fill the width instead of
   the height, and all 22 roles fit on one screen — which is what Kunal asked for and is also
   simply better, because the whole point of this screen is to compare the options.
   auto-fit collapses it back to one column on a narrow window with no media query. */
.rolegroups{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));
  gap:var(--space-300) var(--space-250);align-items:start}
/* The heading rule is the divider — no boxes, so the rows are the only framed thing. */
.rolegh{margin:0 0 var(--space-100);padding-bottom:var(--space-050);
  font-family:var(--font-data);font-size:var(--text-3xs);font-weight:var(--weight-semibold);
  letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-secondary);
  border-bottom:1px solid var(--border-subtle)}
.roletiles{display:flex;flex-direction:column;gap:var(--space-025)}

/* A ROW. Fixed height so no wrapped label can make one taller than its neighbour, and the
   whole row is the target. The native checkbox is replaced by a tick for the same reason as
   the onboarding choices: at 13px it renders as a different shape in every browser. It stays
   in the DOM, focusable, and carries name="roles" on the wizard where it posts itself. */
.roletile{position:relative;display:flex;align-items:center;gap:var(--space-150);
  height:38px;padding:0 var(--space-150) 0 var(--space-100);
  border:1px solid transparent;border-radius:var(--radius-sm);background:transparent;
  cursor:pointer;min-width:0;
  transition:background var(--t-fast) var(--ease),border-color var(--t-fast) var(--ease)}
.roletile:hover{background:var(--bg-hover)}
.roletile:focus-within{border-color:var(--accent);box-shadow:var(--focus-ring)}
.roletile input{position:absolute;opacity:0;width:1px;height:1px;margin:0;pointer-events:none}
/* The tick, drawn as an empty box until chosen so every row shows where the answer lands. */
.roletile::before{content:"";flex:0 0 auto;width:17px;height:17px;border-radius:var(--radius-xs);
  border:1px solid var(--border-strong);background:transparent;
  transition:background var(--t-fast) var(--ease),border-color var(--t-fast) var(--ease)}
.roletile.on,.roletile:has(input:checked){background:var(--accent-bg);border-color:var(--border-default)}
.roletile.on::before,.roletile:has(input:checked)::before{
  background:var(--accent) var(--tick-mark) center/11px no-repeat;border-color:var(--accent)}
.roletile-b{display:flex;align-items:baseline;gap:var(--space-150);flex:1;min-width:0}
.rolelab{flex:1;min-width:0;font-size:var(--text-sm);line-height:1.3;color:var(--ink);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.roletile.on .rolelab{font-weight:var(--weight-semibold)}
/* Right-aligned and tabular so the counts read down the column as a comparison. */
.rolen{flex:0 0 auto;font-family:var(--font-data);font-size:var(--text-2xs);
  font-variant-numeric:tabular-nums;color:var(--text-secondary)}
/* A family with nothing live is shown, not hidden — "0" is the useful answer, and dropping
   it silently would leave someone wondering why their role is missing. */
.roletile.empty{opacity:.55}
/* Past the cap the unticked rows go quiet rather than vanishing, so the limit reads as a
   state instead of options mysteriously disappearing. */
.rolepick.full .roletile:not(.on){opacity:.35}
.rolepick.full .roletile:not(.on):hover{background:transparent;cursor:not-allowed}
.rolenone,.rolehint{margin:0;font-size:var(--text-xs);color:var(--muted)}
/* Where :has() is unavailable the row cannot track its own checkbox, so the native control
   comes back rather than leaving a box that never visibly responds. */
@supports not selector(:has(*)){
  .roletile::before{display:none}
  .roletile input{position:static;opacity:1;width:auto;height:auto;pointer-events:auto;
    accent-color:var(--accent)}
}
/* No inner scroller anywhere. It exists to be read in one go; if the window is genuinely
   too short the modal overlay already scrolls, which is one scrollbar instead of two. */
.rolepick-page{max-width:none}
#rolemodal .modal{max-width:1040px}
/* The feed's trigger: reads as the current selection, not as a button labelled "Roles". */
.rolebtn{display:flex;width:100%;gap:var(--space-100);align-items:center;text-align:left;padding:9px 11px;
  border:1px solid var(--line-strong);border-radius:var(--r);background:var(--surface);
  color:var(--ink);font-size:var(--text-sm);font-family:inherit;cursor:pointer}
.rolebtn:hover{border-color:var(--border-strong);background:var(--bg-hover)}
.rolebtn .rolebtn-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rolebtn .count-pill{flex:0 0 auto}
.rolebtn.fset{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}


/* ==================================================================
   PAGE COMPONENTS lifted out of template <style> blocks (2026-08-09).

   welcome.html and profile.html each carried their own copy of .pgrid, with a comment saying
   the duplication was deliberate so one page could not restyle the other. That was the right
   instinct with no design system to lean on. With tokens there is one definition and both
   pages stay in step by construction.

   Every [data-theme=dark] rule that used to live in those blocks is GONE, not moved. The
   tokens carry both themes, so there is nothing left to override.
   ================================================================== */

/* --- Forms shared by /welcome and /profile --- */
.pgrid{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-200) var(--space-150);max-width:var(--layout-form-max)}
/* justify-content:flex-end is load-bearing. A label whose hint runs to a second line — "Full
   name (fallback if first/last empty)" — made its cell taller, and because the field follows
   the text in a column, the input started lower than the one beside it. Every row of this
   form had one input sitting below its partner. Pushing the contents to the bottom of a
   stretched cell lines the INPUTS up, which is the thing the eye actually tracks down a form. */
.pgrid label{display:flex;flex-direction:column;justify-content:flex-end;font-size:var(--text-2xs);gap:var(--space-050);color:var(--text-secondary)}
.pgrid input,.pgrid select,.pgrid textarea{padding:var(--space-100) var(--space-150);border:1px solid var(--border-default);border-radius:var(--radius-sm);font-size:var(--text-sm);background:var(--bg-surface);color:var(--text-primary);font-family:inherit}
.full{grid-column:1 / -1}
/* A SECTION HEADING, nothing more. It carried a 3px accent bar AND a tinted fill AND an
   asymmetric radius, which made the quietest element on the page the loudest thing on it —
   eight of them stacked down /profile read as eight alerts. A rule and a mono label, the
   same treatment .sechdr and .flab already use, so the page has one idea of what a section
   heading looks like instead of three. */
.psec{grid-column:1 / -1;margin:var(--space-400) 0 var(--space-050);padding:0 0 var(--space-100);
  font-family:var(--font-data);font-size:var(--text-3xs);font-weight:var(--weight-semibold);
  letter-spacing:var(--tracking-wide);text-transform:uppercase;
  color:var(--text-secondary);background:none;border:0;border-bottom:1px solid var(--border-subtle);border-radius:0}
.psec:first-of-type{margin-top:var(--space-150)}
/* The parenthetical after a section name is a sentence, so it drops out of the mono
   uppercase treatment rather than being shouted alongside it. */
.psec .dim{display:block;margin-top:var(--space-050);font-family:var(--font-sans);
  font-size:var(--text-2xs);font-weight:var(--weight-regular);letter-spacing:0;text-transform:none}
.dim{color:var(--text-secondary);font-size:var(--text-xs)}

/* --- The onboarding stepper --- */
.steps{display:flex;gap:var(--space-100);max-width:var(--layout-form-max);margin:0 0 var(--space-100)}
.stepseg{flex:1;height:4px;border-radius:var(--radius-full);background:var(--border-subtle);transition:background var(--t-slow) var(--ease)}
.stepseg.on{background:var(--accent)}
/* Needs the column's max-width of its own, or margin-inline:auto has nothing to centre
   against: a full-width <p> centres its BOX and leaves the text against the page edge,
   which is how the counter ended up 180px left of the bar it counts. */
.stepn{max-width:var(--layout-form-max);font-family:var(--font-data);font-size:var(--text-2xs);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-secondary);margin:0 0 var(--space-250);font-weight:var(--weight-medium)}
.wact{display:flex;align-items:center;gap:var(--space-150);margin-top:var(--space-300);max-width:var(--layout-form-max)}
.wact .spacer{flex:1}
/* Scoped to the WIZARD. Unscoped this also capped the picker inside the modal at 680px, so
   the four groups could only lay out two across and the widened modal had a third of itself
   empty. The wizard wants the form column; the modal wants the whole dialog. */
.wizard ~ .rolepick,.rolepick-page{max-width:var(--layout-form-max)}
.modal .rolepick{max-width:none}
/* A grid item will not shrink below its content unless told to, and a role label is a long
   nowrap string, so without this the four columns collapse back to two. */
.rolegroup{min-width:0}

/* A real ordered list for real sequences. The markers are the data face so the numbers sit
   in a column, and they are the ONLY numbered markers in the app: this is a genuine
   procedure where doing step 3 before step 2 fails, which is the test for using them. */
.steplist{margin:var(--space-100) 0 var(--space-150);padding-left:var(--space-250);
  font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary)}
.steplist li{margin:0 0 var(--space-050);padding-left:var(--space-050)}
.steplist li::marker{font-family:var(--font-data);font-size:var(--text-3xs);color:var(--text-tertiary)}

/* --- /profile: extension token box and the visa timeline --- */
.tokbox,.tlbox{margin-top:var(--space-300);padding:var(--space-200) var(--space-250);border:1px solid var(--border-subtle);border-radius:var(--radius-md);background:var(--bg-sunken);max-width:var(--layout-form-max)}
.tok{font-family:var(--font-mono);font-size:var(--text-xs);background:var(--bg-canvas);color:var(--text-primary);border:1px solid var(--border-subtle);padding:var(--space-150);border-radius:var(--radius-sm);word-break:break-all;margin:var(--space-100) 0}
.tlrow{display:flex;gap:var(--space-150);align-items:baseline;padding:var(--space-100) 0;border-top:1px solid var(--border-subtle);font-size:var(--text-sm)}
.tlrow:first-of-type{border-top:0}
.tlwhen{flex:0 0 84px;font-family:var(--font-data);font-weight:var(--weight-semibold);font-variant-numeric:tabular-nums;text-align:right}
.tlwhat{flex:1}
.tl-past .tlwhen,.tl-urgent .tlwhen{color:var(--status-danger-text)}
.tl-soon .tlwhen{color:var(--status-warn-text)}
.tl-ok .tlwhen{color:var(--status-success-text)}

/* --- /applications --- */
.appstats{display:flex;flex-wrap:wrap;gap:var(--space-150);align-items:center;margin-bottom:var(--space-200)}
.appstats span{font-size:var(--text-xs);color:var(--text-primary)}
.appstats .pill{background:var(--bg-sunken);color:var(--text-secondary);border-radius:var(--radius-full);padding:var(--space-050) var(--space-150);font-weight:var(--weight-semibold);text-transform:capitalize;letter-spacing:0}
.addapp{border:1px solid var(--border-subtle);border-radius:var(--radius-md);padding:var(--space-150) var(--space-200);margin-bottom:var(--space-250);background:var(--bg-sunken)}
.addapp>summary{font-weight:var(--weight-semibold);font-size:var(--text-sm)}
.appform{margin-top:var(--space-150)}
.appform .grid2{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-150)}
.appform label{display:flex;flex-direction:column;font-size:var(--text-2xs);gap:var(--space-050);color:var(--text-secondary);margin-bottom:var(--space-150)}
.appform input,.appform select,.appform textarea,.appedit select,.appedit input{padding:var(--space-100) var(--space-150);border:1px solid var(--border-default);border-radius:var(--radius-sm);font-size:var(--text-xs);background:var(--bg-surface);color:var(--text-primary);font-family:inherit}
.applist{display:flex;flex-direction:column;gap:var(--space-150)}
/* Flat at rest, matching the feed card: a list of twenty of these reads as one surface
   rather than twenty floating tiles, and the border does the separating. */
.appcard{border:1px solid var(--border-subtle);border-radius:var(--radius-md);padding:var(--space-200);background:var(--bg-surface);box-shadow:none;transition:box-shadow var(--t-fast) var(--ease),border-color var(--t-fast) var(--ease)}
.appcard:hover{box-shadow:var(--shadow-sm);border-color:var(--border-strong)}
.appmeta{font-variant-numeric:tabular-nums}
.apphead{display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-150)}
.appco{font-weight:var(--weight-bold);font-size:var(--text-base)}
.apptitle{font-size:var(--text-sm);margin-top:1px}
.apptitle a,.appmeta a{color:var(--accent-text);text-decoration:none}
.appmeta{font-size:var(--text-2xs);color:var(--text-secondary);margin-top:var(--space-050)}
.stbadge{font-size:var(--text-3xs);font-weight:var(--weight-bold);padding:var(--space-050) var(--space-150);border-radius:var(--radius-full);text-transform:capitalize;white-space:nowrap}
.st-saved{background:var(--visa-unknown-bg);color:var(--visa-unknown-text)}
.st-applied{background:var(--status-info-bg);color:var(--status-info-text)}
.st-assessment{background:var(--status-warn-bg);color:var(--status-warn-text)}
.st-interview,.st-offer{background:var(--status-success-bg);color:var(--status-success-text)}
.st-rejected{background:var(--status-danger-bg);color:var(--status-danger-text)}
/* The edit row was a flex wrap of five controls with no shared sizing, so a full-width
   status select sat above a 170px résumé box above a full-width date above notes, and the
   Delete button floated somewhere alongside at whatever height the wrap left it. It reads
   as a form now: four fields on one grid track, actions on their own line at the end.
   auto-fit means it collapses to one column on a phone with no second breakpoint. */
.approw{display:flex;flex-wrap:wrap;gap:var(--space-150);align-items:flex-end;margin-top:var(--space-200);padding-top:var(--space-150);border-top:1px solid var(--border-subtle)}
.appedit{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
  gap:var(--space-100);align-items:end;flex:1 1 420px;margin:0}
.appedit > *{min-width:0}
.appedit .appnotes{grid-column:1 / -1}
.appedit .btn{justify-self:start}
.dform{margin:0}
.empty2{color:var(--text-secondary);padding:var(--space-250);border:1px dashed var(--border-default);border-radius:var(--radius-md);text-align:center}
.setup{margin-top:var(--space-300);font-size:var(--text-xs);color:var(--text-secondary)}
.setup pre{background:var(--bg-sunken);color:var(--text-primary);border:1px solid var(--border-subtle);padding:var(--space-150);border-radius:var(--radius-sm);overflow:auto;font-size:var(--text-2xs)}

/* --- /tailor: the AI rewrite panel --- */
.aibox{margin-top:var(--space-300);padding:var(--space-200) var(--space-250);border:1px solid var(--border-subtle);border-radius:var(--radius-md);background:var(--bg-sunken)}
.aibox h3{margin:0 0 var(--space-100)}
.aibox .muted{font-size:var(--text-2xs);color:var(--text-secondary);margin:0 0 var(--space-100)}
.muted2{font-size:var(--text-2xs);font-weight:var(--weight-semibold);color:var(--status-success-text)}
.airesume{width:100%;box-sizing:border-box;font-family:var(--font-mono);font-size:var(--text-xs);line-height:var(--leading-sm);padding:var(--space-150);border:1px solid var(--border-default);border-radius:var(--radius-sm);background:var(--bg-surface);color:var(--text-primary);resize:vertical}
.keyin{width:100%;max-width:430px;box-sizing:border-box;padding:var(--space-100) var(--space-150);border:1px solid var(--border-default);border-radius:var(--radius-sm);font-size:var(--text-xs);margin:0 0 var(--space-100)}
.rowbtns{margin:var(--space-100) 0}
.aibar{position:relative;height:26px;border-radius:var(--radius-sm);background:var(--bg-active);overflow:hidden;margin:var(--space-150) 0}
.aibar-fill{position:absolute;top:0;height:100%;width:40%;left:-40%;background:var(--accent);animation:aimove 1.15s ease-in-out infinite}
.aibar-txt{position:absolute;inset:0;line-height:26px;text-align:center;font-size:var(--text-2xs);color:var(--text-primary);font-weight:var(--weight-semibold)}
@keyframes aimove{0%{left:-40%}100%{left:100%}}
@media (prefers-reduced-motion:reduce){.aibar-fill{animation:none;left:0;width:100%;opacity:.35}}

/* --- topbar identity link (was inline in base.html) --- */
a.who{color:inherit;text-decoration:none;font-weight:var(--weight-semibold);cursor:pointer}
a.who:hover{text-decoration:underline}

/* --- UTILITIES. Token-backed replacements for the 56 inline style= attributes that used to
   sit in the templates. Deliberately a SMALL set: these exist to delete one-off inline
   declarations, not to become a framework. Anything needed more than a few times should
   become a real component class instead. --- */
.u-hide{display:none}
.u-m0{margin:0}
.u-mt-050{margin-top:var(--space-050)}
.u-mt-100{margin-top:var(--space-100)}
.u-mt-150{margin-top:var(--space-150)}
.u-mt-200{margin-top:var(--space-200)}
.u-mt-250{margin-top:var(--space-250)}
.u-mb-100{margin-bottom:var(--space-100)}
.u-grow{flex:1}
.u-grow-min{flex:1;min-width:0}
.u-tight{width:1%}
.u-w70{width:70%}
.u-max-170{max-width:170px}
.u-max-640{max-width:640px}
.u-p-200{padding:var(--space-200)}
.u-text-2xs{font-size:var(--text-2xs);line-height:var(--leading-2xs)}
.u-text-md{font-size:var(--text-md);line-height:var(--leading-md)}
.u-strongline{font-size:var(--text-base);font-weight:var(--weight-bold);color:var(--text-primary)}
.u-inline-row{display:inline-flex;align-items:center;gap:var(--space-150);margin:var(--space-150) 0}
/* A note that sits tight under the heading it belongs to. */
.u-subnote{margin:calc(var(--space-100) * -1) 0 var(--space-250)}
.u-sechead{margin:var(--space-150) 0 var(--space-100)}
.u-sechead-sm{font-size:var(--text-sm);margin:var(--space-250) 0 var(--space-100)}

/* ==================================================================
   The four states this app had no way to express, plus the tooltip.
   Added 2026-08-09 alongside the select chevron above.
   ================================================================== */

/* --- BUTTON: loading -------------------------------------------------------
   #saveprefs used to just disable itself, so a slow save was indistinguishable
   from a dead button. The label deliberately STAYS visible: swapping it for a
   spinner loses the one piece of information that says which action is running,
   and it makes the button resize mid-click. aria-busy is the accessible half. */
.btn.is-loading{pointer-events:none;opacity:.8}
.btn.is-loading::before{content:"";width:13px;height:13px;flex:0 0 auto;
  border:2px solid currentColor;border-top-color:transparent;
  border-radius:var(--radius-full);animation:spin .7s linear infinite}
@media (prefers-reduced-motion:reduce){.btn.is-loading::before{animation-duration:2.4s}}

/* --- FIELD: error ----------------------------------------------------------
   Keyed on [aria-invalid="true"], not on a .has-error class, so the thing a
   screen reader announces and the thing the eye sees can never disagree. Setting
   the class without the attribute simply does nothing, which is the point. */
.field{display:flex;flex-direction:column;gap:var(--space-050);margin-bottom:var(--space-200)}
.field-label{font-size:var(--text-2xs);color:var(--text-secondary);font-weight:var(--weight-medium)}
.field-help{font-size:var(--text-2xs);color:var(--text-secondary)}
.field-error{font-size:var(--text-2xs);color:var(--status-danger-text);
  font-weight:var(--weight-medium);display:flex;align-items:flex-start;gap:var(--space-050)}
/* A filled circle with a bare "!" typed into it read as a stray character at 10px. Same
   meaning, drawn as one stroke weight that matches every other icon in the app. */
.field-error::before{content:"";flex:0 0 auto;width:13px;height:13px;margin-top:2px;
  background:currentColor;
  -webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 8v5M12 16.5v.01'/%3E%3C/svg%3E") center/contain no-repeat;
  mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 8v5M12 16.5v.01'/%3E%3C/svg%3E") center/contain no-repeat}
input[aria-invalid="true"],select[aria-invalid="true"],textarea[aria-invalid="true"]{
  border-color:var(--status-danger-border)}
input[aria-invalid="true"]:focus,select[aria-invalid="true"]:focus,
textarea[aria-invalid="true"]:focus{border-color:var(--status-danger-border);
  box-shadow:0 0 0 3px var(--status-danger-bg)}

/* --- PASSWORD REVEAL -------------------------------------------------------
   A one-child flex row, not a block: an <input> is inline-block, so a block
   wrapper would add the inline descender gap under it and knock the field a
   few pixels out of line with every other .field in the form.

   The right padding is unconditional. It is what stops a long password running
   under the button, and on a field whose toggle never appears (no JS) it is a
   few pixels of extra breathing room that nobody can detect. Making it
   conditional would mean a class toggle that buys nothing. */
.pw-field{position:relative;display:flex}
.pw-field input{padding-right:44px}
/* Height is 100% of the field rather than a fixed box, so the hit target covers
   the full 43px of the input — a 24px eye floating in the middle of a text field
   is a target people miss on a phone. */
.pw-toggle{position:absolute;top:0;right:0;height:100%;width:40px;
  display:inline-flex;align-items:center;justify-content:center;
  padding:0;border:0;background:none;color:var(--muted);cursor:pointer;
  border-radius:var(--radius-sm);touch-action:manipulation;
  transition:color var(--t-fast) var(--ease)}
/* .pw-toggle sets display, which outranks the UA sheet's [hidden]{display:none}
   on specificity. Without this the button renders before its handler exists. */
.pw-toggle[hidden]{display:none}
.pw-toggle:hover{color:var(--ink)}
.pw-toggle:focus-visible{outline:none;box-shadow:var(--focus)}
.pw-toggle .ic{width:17px;height:17px}

/* --- CALLOUT ---------------------------------------------------------------
   Full borders only. A single-sided accent rule with rounded corners looks like
   a rendering bug, so these are bordered boxes.

   .legal exists for one reason: the immigration disclaimer needs a component of
   its OWN, so that restyling "notes" generally cannot quietly turn a liability
   statement into a decorative info box, and so a reviewer can see at a glance
   that the copy inside is exempt from the house style rules. Do not merge it
   into .callout.info because they currently look similar. */
.callout{padding:var(--space-150) var(--space-200);border-radius:var(--radius-md);
  border:1px solid var(--border-subtle);background:var(--bg-sunken);
  color:var(--text-secondary);font-size:var(--text-xs);line-height:var(--leading-xs);
  margin:0 0 var(--space-200);max-width:var(--layout-prose-max)}
.callout b,.callout strong{color:var(--text-primary)}
.callout.info{background:var(--status-info-bg);border-color:var(--status-info-bg);
  color:var(--status-info-text)}
.callout.warn{background:var(--status-warn-bg);border-color:var(--status-warn-border);
  color:var(--status-warn-text)}
.callout.danger{background:var(--status-danger-bg);border-color:var(--status-danger-border);
  color:var(--status-danger-text)}
.callout.legal{background:var(--bg-sunken);border-color:var(--border-default);
  color:var(--text-secondary)}
.callout.legal a{color:inherit;text-decoration:underline}

/* --- TOOLTIP ---------------------------------------------------------------
   Replaces native title= on the explanations that matter. A native tooltip never
   appears on touch at all, has an uncontrollable delay, and is announced
   inconsistently. tip.js promotes title -> data-tip on first hover or focus, so
   with JS off the native tooltip still works and nothing is lost.

   Fixed position and appended to <body> on purpose: a filter popover scrolls its
   own body, and an absolutely positioned tip inside it would be clipped. */
.tip{position:fixed;z-index:var(--z-toast);max-width:280px;pointer-events:none;
  background:var(--text-primary);color:var(--bg-surface);
  font-size:var(--text-2xs);line-height:var(--leading-2xs);font-weight:var(--weight-regular);
  padding:var(--space-100) var(--space-150);border-radius:var(--radius-sm);
  box-shadow:var(--shadow-md);opacity:0;transform:translateY(2px);
  transition:opacity var(--t-fast) var(--ease),transform var(--t-fast) var(--ease)}
.tip.on{opacity:1;transform:translateY(0)}
@media (prefers-reduced-motion:reduce){.tip{transition:none}}

/* ==================================================================
   ONBOARDING. One question per screen, so the type carries the hierarchy
   rather than a box around every field. Added 2026-08-09 with the four
   question rewrite.
   ================================================================== */
/* CENTRED, not flushed left. One question per screen only works if the question is the
   composition: left-aligned in a 1400px window the column sat against the edge with half
   the page empty beside it, which reads as a form that failed to finish loading rather
   than as a considered screen. The TEXT stays left-aligned inside the column — centred
   prose is harder to read and this is a form, not a poster. */
.steps,.stepn,.wizard,.rolepick-page,.echo{margin-inline:auto}
.wizard{max-width:var(--layout-form-max);margin-top:var(--space-500)}
.steps{margin-top:var(--space-500)}
/* The callout's prose max-width is right in a page of running text and wrong here, where it
   ended 120px short of the choices above it and left the column looking ragged. */
.wizard .callout{max-width:none}

/* The display step. h1 was 24px against a 15px body, a 1.6x ratio, and that timidity is most
   of why these pages read as a form rather than as a designed product. */
.qtitle{font-size:var(--text-2xl);line-height:var(--leading-2xl);letter-spacing:var(--tracking-tight);
  font-weight:var(--weight-medium);margin:0 0 var(--space-150);text-wrap:balance}
.qsub{font-size:var(--text-base);line-height:var(--leading-base);color:var(--text-secondary);
  max-width:46ch;margin:0 0 var(--space-400);text-wrap:pretty}
.qfield input{width:100%}

/* The résumé drop target. Large and primary, because upload is the path we want; the dashed
   border says "put something here" without competing with a real control's solid edge.
   The icon replaces a box that was otherwise four words in an empty rectangle, and it is the
   one place on the screen carrying any illustration at all. */
.dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;
  gap:var(--space-100);text-align:center;padding:var(--space-500) var(--space-200);
  border:2px dashed var(--border-default);border-radius:var(--radius-lg);
  background:var(--bg-surface);cursor:pointer;
  transition:border-color var(--t-fast) var(--ease),background var(--t-fast) var(--ease)}
.dropzone:hover,.dropzone:focus-within{border-color:var(--accent);background:var(--bg-hover)}
.dz-icon{width:30px;height:30px;color:var(--text-tertiary);margin-bottom:var(--space-050);
  transition:color var(--t-fast) var(--ease)}
.dropzone:hover .dz-icon,.dropzone:focus-within .dz-icon{color:var(--text-primary)}
.dz-main{font-size:var(--text-base);font-weight:var(--weight-medium);color:var(--text-primary)}
.dz-sub{font-family:var(--font-data);font-size:var(--text-2xs);color:var(--text-secondary);
  letter-spacing:var(--tracking-wide);text-transform:uppercase}
/* The input still has to be reachable by keyboard and by a screen reader, so it is moved
   off-screen rather than display:none, which would drop it from the tab order entirely. */
/* Chrome enforces an intrinsic minimum on file inputs, so width:1px alone does not shrink it
   to nothing. clip-path is the modern companion to clip and, unlike clip, it also removes the
   leftover box from hit testing. The label above is what opens the picker either way. */
.dropzone input[type=file]{position:absolute;width:1px;height:1px;opacity:0;padding:0;border:0;
  margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}

/* Paste is a disclosure, not a second giant field competing with the drop zone. */
.pastebox{margin:var(--space-200) 0 0}
.pastebox textarea{width:100%;margin-top:var(--space-150)}

/* One decision per screen: big targets, the label doing the work, no radio dot to aim at.
   The native radio is hidden rather than tinted — at 13px it is a different shape in every
   browser and it competed with the tick that actually marks the chosen row. It stays in the
   DOM and stays focusable, so keyboard and screen readers are unaffected. */
.choices{display:flex;flex-direction:column;gap:var(--space-150);margin:0 0 var(--space-300)}
.choice{position:relative;display:grid;grid-template-columns:1fr auto;gap:0 var(--space-150);align-items:start;
  padding:var(--space-200);border:1px solid var(--border-default);border-radius:var(--radius-md);
  background:var(--bg-surface);cursor:pointer;
  transition:border-color var(--t-fast) var(--ease),background var(--t-fast) var(--ease),box-shadow var(--t-fast) var(--ease)}
.choice:hover{border-color:var(--border-strong);background:var(--bg-hover)}
.choice:focus-within{border-color:var(--accent);box-shadow:var(--focus-ring)}
.choice input{position:absolute;opacity:0;width:1px;height:1px;margin:0;pointer-events:none}
.choice-b{grid-column:1;font-size:var(--text-base);font-weight:var(--weight-medium);color:var(--text-primary)}
.choice-h{grid-column:1;font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px}
/* The tick. Drawn as an empty ring until chosen, so every row shows where the answer will
   land rather than the chosen one growing a control the others do not have. */
.choice::after{content:"";grid-column:2;grid-row:1 / span 2;align-self:center;
  width:20px;height:20px;border-radius:var(--radius-full);
  border:1px solid var(--border-strong);background:transparent;
  transition:background var(--t-fast) var(--ease),border-color var(--t-fast) var(--ease)}
/* TWO selectors, and both are needed. .on is what the SERVER renders for the answer already
   saved. :has() is what responds to a click, and without it this screen would be silent:
   welcome.html has no JavaScript on this step by design, so nothing would repaint the row
   the moment the native radio stopped being the visible control. */
.choice.on,.choice:has(input:checked){border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
.choice.on::after,.choice:has(input:checked)::after{
  background:var(--accent) var(--tick-mark) center/12px no-repeat;border-color:var(--accent)}
/* Where :has() is unavailable the ring cannot track the answer, so the native radio comes
   back rather than leaving a control that never visibly responds. */
@supports not selector(:has(*)){
  .choice{grid-template-columns:auto 1fr}
  .choice::after{display:none}
  .choice input{position:static;opacity:1;width:auto;height:auto;
    grid-column:1;grid-row:1 / span 2;align-self:center;margin:0;
    pointer-events:auto;accent-color:var(--accent)}
  .choice-b,.choice-h{grid-column:2}
}

@media (max-width:560px){
  .qtitle{font-size:var(--text-xl);line-height:var(--leading-xl)}
  .dropzone{padding:var(--space-400) var(--space-150)}
}

/* The parse echo. Rendered only by the React island on question 1, after an upload has
   actually been read, so this is the moment the product proves the parser works instead of
   asking the user to assume it. */
.echo{border:1px solid var(--border-subtle);border-radius:var(--radius-md);
  background:var(--bg-sunken);padding:var(--space-250);max-width:var(--layout-form-max)}
.echo-h{margin:0 0 var(--space-150);font-size:var(--text-base);
  font-weight:var(--weight-medium);color:var(--text-primary)}
.echo-chips{display:flex;flex-wrap:wrap;gap:var(--space-100);margin:0 0 var(--space-150)}
/* The chips reuse .vt, the visa-route chip shape, so nothing new is invented for one screen.
   margin-left is cleared because .vt assumes it is trailing a card title, not sitting in a
   wrapped row of its own. */
.echo-chips .vt{margin-left:0}
.echo .dim{margin:0}
.pastelink{margin:var(--space-200) 0 0}
.pastelink .btn{padding-left:0}
.pastebox.is-open textarea{width:100%;margin:0 0 var(--space-150)}

/* ==================================================================
   FILTER BAR. Replaces the 280px rail (2026-08-09).

   A chip row is self-documenting where a rail is not: an inactive rail showed fourteen
   controls set to nothing, this shows exactly what is narrowing the list right now. Left
   rails survive where faceting IS the browsing method and the catalogue is unfamiliar
   (Amazon, Booking). Chip rows dominate where the user has a standing intent and the system
   already ranks (LinkedIn Jobs, Indeed, Google Flights, Zillow). This product has a saved
   search, a ranked feed and a résumé-derived score, so it is squarely the second group.
   ================================================================== */
.filterbar{position:sticky;top:var(--topbar-h);z-index:var(--z-sticky);
  display:flex;align-items:center;gap:var(--space-150);flex-wrap:wrap;
  padding:var(--space-150) 0;margin:0 0 var(--space-200);
  background:var(--bg-canvas);border-bottom:1px solid var(--border-subtle)}
.fsearch{flex:1 1 260px;min-width:200px}
.fsearch input{width:100%;height:var(--field-h);padding-top:0;padding-bottom:0}
.fchips{display:flex;align-items:center;gap:var(--space-100);flex-wrap:wrap;min-width:0}

/* An inactive chip names the dimension. An active one carries its VALUE, which is what turns
   the row into a sentence about the current search. */
.fchip{display:inline-flex;align-items:center;gap:var(--space-100);height:var(--field-h);
  padding:0 var(--space-200);border:1px solid var(--border-default);
  border-radius:var(--radius-full);background:var(--bg-surface);color:var(--text-secondary);
  font:inherit;font-size:var(--text-xs);font-weight:var(--weight-semibold);
  white-space:nowrap;cursor:pointer;touch-action:manipulation;
  transition:border-color var(--t-fast) var(--ease),background var(--t-fast) var(--ease),
             color var(--t-fast) var(--ease),box-shadow var(--t-fast) var(--ease)}
.fchip:hover{border-color:var(--border-strong);color:var(--text-primary);box-shadow:var(--shadow-xs)}
.fchip:focus-visible{outline:none;box-shadow:var(--focus-ring)}
/* An open chip and a SET chip are different states and must look different. Open borrows
   the focus ring; set goes solid ink, which is the strongest thing the chrome can say and
   costs nothing from the colour budget the visa chips need. */
.fchip[aria-expanded="true"]{border-color:var(--accent);color:var(--text-primary);box-shadow:var(--focus-ring)}
.fchip.on{background:var(--accent);border-color:var(--accent);color:var(--text-on-accent)}
.fchip.on .fchip-l{color:var(--text-on-accent);opacity:.65;font-weight:var(--weight-medium)}
.fchip.on .fchip-c{opacity:.75}
/* The VALUE is the data half of the chip, so it takes the data face: "Past 30 days",
   "Boston, MA", "3 roles" all read as settings rather than as prose. */
.fchip-v{font-family:var(--font-data);font-size:var(--text-3xs);font-weight:var(--weight-semibold);
  letter-spacing:0;max-width:19ch;overflow:hidden;text-overflow:ellipsis}
.fchip-v:empty{display:none}
.fchip-c{width:11px;height:11px;opacity:.55}
.fchip-more{margin-left:auto}
.fchip-more .fbadge{background:var(--accent);color:var(--text-on-accent);
  border-radius:var(--radius-full);min-width:18px;padding:0 5px;text-align:center;
  font-family:var(--font-data);font-size:var(--text-3xs);font-weight:var(--weight-semibold);line-height:18px}
/* An ink chip needs an ink-on-ink badge, or the count vanishes into the fill. */
.fchip-more.on .fbadge{background:var(--text-on-accent);color:var(--accent)}

/* Popovers sit OUTSIDE .filterbar so a sticky, wrapping bar cannot clip them. Absolute rather
   than fixed so they scroll with the bar they belong to. */
/* Bounded and scrollable. Once every control in "More filters" grew a real label and a real
   explanation the panel outgrew the viewport, and the footer — which holds the primary
   "Show N jobs" action — fell off the bottom of the screen with no way to reach it.
   The actual max-height is measured onto the element by placePop() in app.js, because it
   depends on where the chip that opened it happens to sit; this is the no-JS floor.
   overscroll-behavior stops a flick at the end of the list from scrolling the feed behind. */
.fpop{position:absolute;z-index:var(--z-dropdown);min-width:260px;max-width:340px;
  max-height:80vh;overflow-y:auto;overscroll-behavior:contain;
  padding:var(--space-200);border:1px solid var(--border-subtle);
  border-radius:var(--radius-lg);background:var(--bg-raised);box-shadow:var(--shadow-lg);
  animation:popFade .16s ease}
@keyframes popFade{from{opacity:0;transform:translateY(-4px) scale(.97)}to{opacity:1;transform:none}}
.fpop[hidden]{display:none}
.fpop-wide{min-width:340px;max-width:420px}
/* Section labels get the data face: uppercase micro-type is what monospace is FOR, and it
   keeps the popovers reading as one system with the numbers on the cards behind them. */
.fpop .flab{margin:0 0 var(--space-150);font-family:var(--font-data);font-size:var(--text-3xs);
  font-weight:var(--weight-semibold);text-transform:uppercase;
  letter-spacing:var(--tracking-wide);color:var(--text-secondary)}
.fpop .fgroup+.fgroup{margin-top:var(--space-250);padding-top:var(--space-200);
  border-top:1px solid var(--border-subtle)}
.fpop .sel,.fpop .locin{width:100%}
.fpop .ck{display:flex;margin:var(--space-100) 0}
/* Every control in "More filters" is a labelled field now rather than a bare select with
   its explanation hidden in a title= attribute. A hover tooltip is not a label: it does not
   exist on touch, and on a filter whose whole job is to say what it will hide, the
   explanation IS the interface. */
.fpop .field{margin-bottom:var(--space-200)}
.fpop .field:last-child{margin-bottom:0}
.fpop .field-label{font-weight:var(--weight-medium);color:var(--text-primary);
  font-size:var(--text-xs)}
.fpop .field-help{margin:0;line-height:var(--leading-2xs)}
/* Sticky to the bottom of the popover's own scroller, so "Show N jobs" is reachable
   without scrolling to the end of a long filter list to find it. It resolves against the
   .fpop scroll container rather than the viewport, which is why this is safe here and was
   not on the old rail footer. */
/* JOB TYPE: two things you can want, ticked independently, over a hidden #intern select. */
.typeboxes{display:flex;flex-direction:column;gap:var(--space-100);margin:var(--space-050) 0}
.typeboxes .ck{font-size:var(--text-sm);color:var(--text-primary)}

/* THE COLOUR SWATCH, on the row it describes rather than in a key underneath it. Each one
   is the card fill for that route, straight from the token, so the swatch and the feed cannot
   disagree. Pushed to the right edge so the swatches line up as a column of their own. */
.visack,.ck-static{display:flex;align-items:center;gap:var(--space-150)}
.visack-l{flex:1;min-width:0}
.ck-static{cursor:default;color:var(--text-tertiary)}
/* The SATURATED value, not the card fill. A 22x14 chip of the card's own pale tint is very
   nearly invisible, which defeats the point of putting it here; the ink is what the Apply button
   wears, at a size where it can actually be seen.

   These stopped telling the routes apart when the palette collapsed, and that is fine HERE: this
   is a list of checkboxes whose own labels name the routes, so the swatch marks a row as a route
   filter rather than identifying which. .sw-none keeps the pale fill and a ring, because "no
   record" is the one option in the list that is not a route. */
.sw{flex:0 0 auto;width:20px;height:12px;border-radius:var(--radius-full)}
.sw-h1b,.sw-e3,.sw-h1b1,.sw-green_card,.sw-stem_opt,.sw-blocked{background:var(--route-h1b-ink)}
.sw-none{background:var(--route-none-fill);box-shadow:inset 0 0 0 1px var(--route-h1b-ink)}
/* The last row has no checkbox, so its label needs the checkbox's width to line up with the
   ones above it. 13px control + the row gap. */
.ck-static .visack-l{padding-left:calc(13px + var(--space-100))}

.fpopfoot{position:sticky;bottom:calc(var(--space-200) * -1);
  display:flex;align-items:center;gap:var(--space-100);
  margin:var(--space-250) calc(var(--space-200) * -1) calc(var(--space-200) * -1);
  padding:var(--space-200);
  background:var(--bg-raised);border-top:1px solid var(--border-subtle)}

/* GONE 2026-09-03, with the auto-fill grid it widened. This was the other half of the filter
   bar's "reclaimed width" bargain -- a smaller card minimum and a bigger gap, to get four
   columns at 1440px instead of three. The width is still reclaimed; it is spent on FACTS per
   card now (see .feedgrid, which is a fixed three), because four narrow cards is four things to
   open and three carrying their pay and their years is three decisions.

   IT WAS AN ID SELECTOR AND THAT IS WHY IT HAD TO BE DELETED RATHER THAN OVERRIDDEN. #feed
   beats .feedgrid on specificity no matter what order they appear in, so the class rule was
   silently losing above 1200px -- the feed rendered four auto-fill columns while every card
   inside it was styled for the new layout. Nothing warns you: both rules are valid, both
   applied, and only the computed value tells you which won. */

@media (max-width:900px){
  /* The bar scrolls horizontally with Filters pinned to the right, which is Airbnb's and
     Google's pattern and keeps the escape hatch reachable without scrolling to find it. */
  .filterbar{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
  .filterbar::-webkit-scrollbar{display:none}
  .fsearch{flex:1 0 60%}
  .fchips{flex-wrap:nowrap}
  .fchip-more{position:sticky;right:0;margin-left:0;
    box-shadow:-12px 0 12px -6px var(--bg-canvas)}
  .fpop{position:fixed;left:var(--space-150);right:var(--space-150);
    max-width:none;min-width:0;max-height:70vh;overflow:auto}
}

/* The empty state stops guessing. web._relax_suggestions works out which single filter, if
   dropped, brings back the most jobs, so this names a VALUE and offers to undo it rather than
   naming a control and leaving the reader to go and look at what it was set to. */
.empty-h{font-size:var(--text-md);font-weight:var(--weight-medium);color:var(--text-primary);margin:0 0 var(--space-100);text-wrap:balance}
.empty-acts{display:flex;flex-wrap:wrap;gap:var(--space-100);justify-content:center;margin-top:var(--space-200)}

/* ---- RÉSUMÉ SCORE (/resume) -------------------------------------------------------------
   The score reuses .score-ring and the --match-* ramp deliberately: the feed already teaches
   "ring + number = a score", so a second scale does not need a second visual language. The
   BANDS differ though (85/70 here vs 55/42 for job match) because they measure different
   things, which is why the thresholds live in the template and not in a shared class.

   The meter colours are re-scoped rather than hoisted to a global utility: .meter-* already
   exists twice under .jobstat/.simmeta, and a third global definition is how a token ends up
   painting something nobody intended. */
.rs-head{display:flex;align-items:center;gap:var(--space-250);flex-wrap:wrap;margin:0 0 var(--space-250)}
.rs-headtext{min-width:0;display:flex;flex-direction:column;gap:var(--space-050)}
.rs-levels{display:flex;align-items:center;gap:var(--space-100);flex-wrap:wrap;
  margin-top:var(--space-100);font-size:var(--text-2xs);color:var(--muted);font-weight:600}
.rs-groups{margin:0}
.rs-groups .tile-n.meter-strong{color:var(--match-strong)}
.rs-groups .tile-n.meter-good{color:var(--match-good)}
.rs-groups .tile-n.meter-weak{color:var(--match-weak)}

.rs-fixes{margin:0;padding-left:var(--space-250);display:flex;flex-direction:column;gap:var(--space-250)}
.rs-fixes>li::marker{color:var(--muted);font-weight:700}
.rs-fixhead{display:flex;align-items:center;gap:var(--space-100);flex-wrap:wrap}
.rs-detail{color:var(--text-secondary);font-size:var(--text-sm);margin-top:2px}
/* The instruction, not the complaint. Given a left rule so the eye can find the ONE line that
   says what to do, without the wash of colour that would compete with the visa chips. */
.rs-fix{margin-top:var(--space-100);padding-left:var(--space-150);
  border-left:2px solid var(--line-strong);font-size:var(--text-sm);color:var(--text-primary)}
/* The general rule, demoted. It used to BE the advice, which is why every report read the same;
   now it sits under the instruction as the principle behind it. Continues .rs-fix's left rule in
   a lighter weight so the two read as one block, not two competing sentences. */
.rs-rule{padding-left:var(--space-150);border-left:2px solid var(--line);
  font-size:var(--text-xs);color:var(--text-tertiary);padding-top:4px}
/* Hand-off to the Bullets tab. A button, not a link — it changes what is on screen rather than
   navigating, and a link that does not navigate lies to a screen reader. */
.rvxtab{margin-top:var(--space-100);margin-left:var(--space-150);background:none;border:0;
  padding:0;font:inherit;font-size:var(--text-xs);font-weight:var(--weight-medium);
  color:var(--brand);cursor:pointer;text-align:left}
.rvxtab:hover{text-decoration:underline}
.rvxtab:focus-visible{outline:2px solid var(--brand);outline-offset:2px;border-radius:2px}
/* Verbatim offending lines. Monospace because these are quoted FROM the résumé — the reader has
   to be able to find them in the textarea below, and a proportional face invites reading them
   as our prose instead of theirs. */
.rs-off{margin:var(--space-100) 0 0;padding-left:var(--space-200);list-style:none;
  display:flex;flex-direction:column;gap:4px}
.rs-off li{font-family:var(--font-data);font-size:var(--text-2xs);color:var(--muted);
  line-height:1.5;padding-left:var(--space-100);border-left:1px solid var(--line)}

.rs-sum{font-size:var(--text-md);font-weight:var(--weight-semibold);color:var(--text-primary)}
.rs-table{width:100%;border-collapse:collapse;margin-top:var(--space-150);font-size:var(--text-sm)}
.rs-table th,.rs-table td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);
  vertical-align:top}
.rs-table thead th{font-size:var(--text-2xs);text-transform:uppercase;letter-spacing:.04em;
  color:var(--muted);font-weight:700}
.rs-table tbody th{width:130px;color:var(--muted);font-weight:600}
.rs-table tr:last-child th,.rs-table tr:last-child td{border-bottom:0}
.rs-num{font-family:var(--font-data);font-variant-numeric:tabular-nums;text-align:right;
  white-space:nowrap}
.rs-table .meter-strong{color:var(--match-strong)}
.rs-table .meter-good{color:var(--match-good)}
.rs-table .meter-weak{color:var(--match-weak)}
/* The checks table is 5 columns; at 375px it was clipped INSIDE its panel, which is worse than
   a scrollbar because nothing tells the reader there is more. Group is the column to drop — the
   rows are already ordered by cost, so the grouping is decoration here — and the scroll wrapper
   catches whatever still does not fit rather than hiding it. */
.rs-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
/* What an ATS sees. Collapsed by default: it is reference, and the ranked fixes are the work.
   Sits under the fix list in .rvsplit-a, so it must not inherit the tiles' 190px minmax at rail
   width -- four tiles in a 420px column would each be one word wide. */
.rvats{margin-top:var(--space-250);border-top:1px solid var(--line);padding-top:var(--space-200)}
.rvats > summary{cursor:pointer;list-style:none}
.rvats > summary::-webkit-details-marker{display:none}
.rvats > summary::after{content:'  +';color:var(--muted);font-weight:400}
.rvats[open] > summary::after{content:'  2'}
.rvats > summary:focus-visible{outline:2px solid var(--brand);outline-offset:3px;border-radius:2px}
.rvats .tiles{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--space-100);
  margin:var(--space-150) 0}
.rvats .tile{padding:11px 13px}
.rvats .rs-table th{text-align:left;font-weight:var(--weight-medium);color:var(--muted);
  padding:6px 12px 6px 0;white-space:nowrap;vertical-align:top}
.rvats .rs-table td{padding:6px 0;color:var(--text-primary)}
@media (max-width:640px){
  .rs-head{gap:var(--space-150)}
  .rs-table tbody th{width:auto}
  .rs-col-group{display:none}
  .rs-off li{overflow-wrap:anywhere}
}

/* ---- RESUME BRAIN REVIEW PANEL (/brain) --------------------------------------------------
   Two columns, mirroring .jobcols (the job page) rather than inventing a third grid: rail
   left, document right, one breakpoint, collapsing to a single column on a phone.

   min-width:0 on BOTH children is load-bearing, same as .jobcols. Without it a long unbroken
   run inside <pre class="rv-doc"> pushes the grid track past the container and the whole page
   scrolls sideways -- the exact bug that once made the feed scroll horizontally. */
.rvcols{display:grid;grid-template-columns:1fr;gap:var(--space-400)}
.rvrail{min-width:0;display:flex;flex-direction:column;gap:var(--space-200)}
.rvmain{min-width:0}
@media (min-width:1040px){
  .rvcols{grid-template-columns:280px minmax(0,1fr)}
  /* Sticky only where there is room. On a phone a sticky rail eats the viewport. */
  .rvrail{position:sticky;top:calc(var(--topbar-h) + var(--space-200));align-self:start;
    max-height:calc(100vh - var(--topbar-h) - var(--space-400));overflow-y:auto}
}

.rvbox{background:var(--surface);border:1px solid var(--border-card);border-radius:var(--r-lg);padding:14px 16px}
.rvbox-h{font-size:var(--text-sm);font-weight:700;color:var(--ink);margin:0 0 10px;
  display:flex;align-items:center;gap:8px}
.rvhint{font-size:var(--text-2xs);color:var(--muted);margin:0 0 8px}

/* Upload sits in the rail because adding a résumé is the one action that always makes sense,
   including when the panel has nothing to show yet. */
.rvupload{margin-top:12px;display:flex;flex-direction:column;gap:8px}
/* .dz-sm -- the drop zone at rail scale. Replaced .rvfile, which styled nothing and left the
   browser's native "Choose File" button on screen: the one control in the app that still looked
   like a 2005 form. The full-size .dropzone is 40px of vertical padding, which in a 300px rail
   pushed the Upload button below the fold, so only the metrics change here. min-width:0 because
   the rail is a grid track and a dashed box with a long filename in it would otherwise refuse to
   shrink and blow the column out. */
.dz-sm{padding:var(--space-200) var(--space-150);gap:var(--space-050);min-width:0}
.dz-sm .dz-icon{width:22px;height:22px;margin-bottom:0}
.dz-sm .dz-main{font-size:var(--text-xs);line-height:1.35}
.dz-sm .dz-sub{font-size:var(--text-3xs)}
/* A filename is the one thing here that can't be broken across words, so it gets to break
   anywhere rather than widen the rail. */
.dz-sm .dz-main[data-dz-label]{overflow-wrap:anywhere}
/* Drag state, set by dropzone.js. Same treatment as :hover rather than a louder one: the zone
   is already the only thing under the pointer, so this only has to confirm it is listening. */
.dropzone.dz-over{border-color:var(--accent);background:var(--accent-bg)}
.dropzone.dz-over .dz-icon{color:var(--accent-text)}

.rvgroup{border-top:1px solid var(--line);padding-top:6px;margin-top:6px}
.rvgroup:first-of-type{border-top:0;margin-top:0}
.rvgroup > summary{display:flex;align-items:center;justify-content:space-between;gap:8px;
  padding:4px 0;font-size:var(--text-sm);font-weight:600}
.rvg-name{flex:1}
/* A check with marks is a control; one without is a readout. They must not look alike, or every
   unclickable row invites a click that does nothing. */
.rvcheck{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;
  background:transparent;border:0;border-radius:var(--radius-sm);padding:5px 6px;margin:1px 0;
  font-size:var(--text-2xs);color:var(--text-secondary);text-align:left;cursor:pointer;
  font-family:inherit}
.rvcheck:hover{background:var(--bg-hover);color:var(--text-primary)}
.rvcheck.flat{cursor:default}
.rvcheck.flat:hover{background:transparent;color:var(--text-secondary)}
.rvcheck.sel{background:var(--accent-bg);color:var(--accent-text);font-weight:700}
.rvc-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rvc-n{font-family:var(--font-data);font-variant-numeric:tabular-nums;flex:0 0 auto}

.rvtabs{display:flex;gap:2px;flex-wrap:wrap;margin-top:var(--space-200);
  border-bottom:1px solid var(--line)}
.rvtab{background:transparent;border:0;border-bottom:2px solid transparent;padding:8px 12px;
  font-size:var(--text-sm);font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit}
.rvtab:hover{color:var(--text-primary)}
.rvtab.on{color:var(--text-primary);border-bottom-color:var(--accent)}

.rvdoc-h{margin-top:var(--space-300)}
/* The document. <pre> because this is the text an ATS extracted -- collapsing its whitespace
   would misrepresent the very thing the panel exists to show. */
.rv-doc{margin:0;padding:16px;background:var(--surface2);border:1px solid var(--line);
  border-radius:var(--r-lg);font-family:var(--font-data);font-size:var(--text-2xs);
  line-height:1.75;white-space:pre-wrap;overflow-wrap:anywhere;max-height:70vh;overflow-y:auto;
  color:var(--text-primary)}
/* Inert by default. Every span in the document is already wrapped; only the selected check lights
   up. Reuses the JD highlighter's colour pair so a marked résumé and a marked JD read the same. */
.rv-doc mark{background:transparent;color:inherit;padding:0}
.rv-doc mark.on{background:var(--kw-miss-bg,var(--status-warn-bg));color:var(--kw-miss-text,var(--status-warn-text));
  border-radius:3px;box-shadow:0 0 0 1px var(--status-warn-border,transparent)}

.rs-fixes > li.linked{cursor:pointer}
.rs-fixes > li.sel > .rs-fixhead > strong{color:var(--accent-text)}
.rvjump{font-size:var(--text-3xs);color:var(--accent-text);text-transform:uppercase;
  letter-spacing:.04em;font-weight:700}
.rvpdf{width:100%;height:min(78vh,900px);margin-top:var(--space-200);border:1px solid var(--line);
  border-radius:var(--r-lg);background:var(--surface2)}
@media (max-width:640px){
  .rv-doc{font-size:var(--text-3xs);padding:10px}
  .rvpdf{height:60vh}
}

/* The gate banner. Warn tones, not danger: a capped score is a diagnosis, not an error, and this
   sits directly under a score ring whose colour already carries the verdict. */
.rvgate{margin-top:var(--space-200);line-height:1.6}
.rvgate strong{display:block;margin-bottom:2px}

/* ---- per-bullet review (the Bullets tab) ------------------------------------------------
   The three parts are shown as three chips per bullet, present or absent, because "6.5/10" says
   a bullet is weak and "verb SCOPE result" says which third of it to fix. */
.rvtab-n{font-family:var(--font-data);font-size:var(--text-3xs);font-weight:700;margin-left:5px;
  padding:1px 5px;border-radius:var(--radius-full);background:var(--surface2);color:var(--muted)}
.rvblist{margin:var(--space-250) 0 0;padding-left:var(--space-250);display:flex;
  flex-direction:column;gap:var(--space-200)}
.rvblist>li::marker{color:var(--muted);font-weight:700}
.rvb{padding-bottom:var(--space-200);border-bottom:1px solid var(--line)}
.rvb:last-child{border-bottom:0;padding-bottom:0}
.rvb-head{display:flex;align-items:center;gap:var(--space-150);flex-wrap:wrap}
.rvb-score{font-family:var(--font-data);font-variant-numeric:tabular-nums;font-weight:800;
  font-size:var(--text-md)}
.rvb-parts{display:flex;gap:4px}
/* Absent parts are the message, so they get the border and the present ones recede. Colour is not
   the carrier here -- the visa chips already own colour on this app -- weight and fill are. */
.rvb-part{font-size:var(--text-3xs);text-transform:uppercase;letter-spacing:.05em;font-weight:700;
  padding:2px 7px;border-radius:var(--radius-full);border:1px dashed var(--line-strong);
  color:var(--muted);background:transparent}
.rvb-part.has{border-style:solid;border-color:transparent;background:var(--surface2);
  color:var(--text-primary)}
.rvb-text{margin-top:var(--space-100);font-family:var(--font-data);font-size:var(--text-2xs);
  line-height:1.6;color:var(--text-primary);overflow-wrap:anywhere}
.rvb-probs{margin:var(--space-100) 0 0;padding-left:var(--space-200);color:var(--text-secondary);
  font-size:var(--text-sm);display:flex;flex-direction:column;gap:2px}
.rvb-fix{margin-top:6px;padding-left:var(--space-150);border-left:2px solid var(--line-strong);
  font-size:var(--text-sm);color:var(--text-primary)}
.rvb-fix b{color:var(--muted);font-weight:700;margin-right:4px}
.rvb.good .rvb-text{color:var(--text-secondary)}
.rvfind{margin-top:var(--space-250);font-size:var(--text-sm)}
.rvfind ul{margin:var(--space-100) 0 0;padding-left:var(--space-250);display:flex;
  flex-direction:column;gap:4px;color:var(--text-secondary)}

/* ---- Resume Brain UX pass ----------------------------------------------------------------
   MOBILE ORDER. The rail is three boxes and an upload form; on a phone that pushed the score and
   the document roughly a screen and a half down, so the page opened on navigation instead of on
   the answer. The score panel comes first below 1040px and the rail follows it. Source order stays
   rail-first because that is the right order for a screen reader and for the desktop layout. */
@media (max-width:1039px){
  .rvcols{display:flex;flex-direction:column}
  .rvmain{order:1}
  .rvrail{order:2}
}

/* The Brain spine. Same switcher as the admin pages; only the spacing differs, because here it
   sits above a two-column grid rather than a single column. */
.brainnav{margin-bottom:var(--space-250)}

/* Library links in the rail. A count with no label is not information -- "Stories 0" told the
   reader nothing, and every account reads zero today. */
.rvlink{display:block;padding:8px 6px;border-radius:var(--radius-sm);text-decoration:none;
  border-top:1px solid var(--line)}
.rvlink:first-of-type{border-top:0}
.rvlink:hover{background:var(--bg-hover)}
.rvlink-t{display:block;font-size:var(--text-sm);font-weight:650;color:var(--text-primary)}
.rvlink-t b{font-family:var(--font-data);font-variant-numeric:tabular-nums;color:var(--muted);
  margin-left:4px}
.rvlink-d{display:block;margin-top:2px;font-size:var(--text-2xs);color:var(--muted);line-height:1.5}

/* Empty state. An empty screen is an invitation to act, so it says what happens next rather than
   describing the feature in the abstract. */
.rvsell{margin:var(--space-200) 0;padding-left:var(--space-250);display:flex;flex-direction:column;
  gap:var(--space-100);color:var(--text-secondary);font-size:var(--text-sm);line-height:1.6}
.rvsell b{color:var(--text-primary)}

/* Focus. These are new interactive elements and the app's focus ring has to reach them, or the
   keyboard path is invisible. */
.rvtab:focus-visible,.rvcheck:focus-visible,.rvlink:focus-visible{outline:none;
  box-shadow:var(--focus-ring);border-radius:var(--radius-sm)}
.rvpanel:focus-visible{outline:none;box-shadow:var(--focus-ring)}

@media (prefers-reduced-motion: reduce){
  .rv-doc{scroll-behavior:auto}
}

/* The panel's h1. It has to read as a document name beside an 86px score ring, not as a page
   banner, so it takes the weight of the old .u-strongline rather than the global h1 scale. */
.rvtitle{margin:0;font-size:var(--text-md);font-weight:var(--weight-semibold);line-height:1.3;
  color:var(--text-primary);letter-spacing:var(--tracking-tight)}
.rvtitle-s{font-weight:var(--weight-medium);color:var(--muted)}
/* What the band actually means. Sits between the score and the "25 checks" line because it is
   the sentence that turns a number into a position. */
.rvbandnote{margin-top:var(--space-050);font-size:var(--text-sm);color:var(--text-primary);
  max-width:62ch}
.rvtitle-s::before{content:"· ";color:var(--line-strong)}

/* The tailor form. .rb centres at 920px, which looked adrift once the results below it became a
   two-column grid; this left-aligns it so the form and the results' main column share an edge. */
.brainform{max-width:880px;margin:0 0 var(--space-300)}
/* The results aside carries reference material, so its cards are quieter than the action cards in
   the main column -- same components, less emphasis, which is what "context" should look like. */
.jobaside .card h2{font-size:var(--text-sm)}
@media (max-width:1039px){
  .brainform{max-width:none}
}

/* Widens .rb for the tailor page without removing the scope. Every result card on that page is
   styled by `.rb <something>` selectors -- .cov, .coach, .storypick, .chips, .badge and fifteen
   more -- so swapping .rb for .jobwrap unstyled all of them at once. This keeps the scope and only
   changes the width, matching the review panel's container so moving between the two does not jump.
   Appended after both .jobwrap and .rb, so it wins on source order. */
.brainwide{max-width:var(--layout-feed-max);margin:0 auto;
  padding:0 var(--space-300) var(--space-600)}

/* ---- LAYOUT REVAMP: use the width, and stop making everything tiny --------------------------
   The complaint was exact: a 1900px viewport rendering one ~900px column of short lines with the
   right half empty, at 12px. The answer is not wider text lines -- a 1500px line of prose is
   unreadable -- it is more content side by side, and a larger base size. */

/* Rail 300px, not 280: at 280 the résumé rows could not fit a name and a score on one line. */
@media (min-width:1040px){
  .rvcols{grid-template-columns:300px minmax(0,1fr)}
}

/* Résumé rows: score in a fixed column so the numbers align and can be scanned down. */
.rvreslist{display:flex;flex-direction:column;gap:2px;margin-bottom:var(--space-100)}
/* Preview and Make-live sit on ONE row. The button is revealed on hover/focus rather than shown
   always, because in a 300px rail four permanent buttons compete with the four résumé names
   they belong to -- but it is never display:none, so it stays in the tab order and a keyboard
   user finds it by tabbing. On a touch screen there is no hover, so it is always visible. */
.rvres-row{display:flex;align-items:center;gap:var(--space-050);border-radius:var(--radius-sm)}
.rvres-row:hover,.rvres-row:focus-within{background:var(--bg-hover)}
.rvres-row.on{background:var(--accent-bg)}
.rvres-row.on .rvres-t{color:var(--accent-text);font-weight:700}
.rvres-row>.rvres{flex:1 1 auto;min-width:0}
.rvres-go{flex:0 0 auto;margin:0}
.rvres-live{font:inherit;font-size:var(--text-3xs);font-weight:700;white-space:nowrap;
  padding:4px 8px;margin-right:4px;border:1px solid var(--border-default);
  border-radius:var(--radius-xs);background:var(--bg-surface);color:var(--text-secondary);
  cursor:pointer;opacity:0;transition:opacity var(--t-fast) var(--ease),
  border-color var(--t-fast) var(--ease),color var(--t-fast) var(--ease)}
.rvres-row:hover .rvres-live,.rvres-live:focus-visible{opacity:1}
.rvres-live:hover{border-color:var(--accent);color:var(--accent-text)}
@media (hover:none){.rvres-live{opacity:1}}
.rvres{display:flex;align-items:center;gap:var(--space-150);padding:8px 8px;border-radius:var(--radius-sm);
  text-decoration:none;color:inherit}
/* Highlighting moved up to .rvres-row, which is what the user is actually pointing at now that
   the row holds two controls. Hovering the link alone would light the link and leave the button
   beside it on the old background, which reads as two rows rather than one.
   .rvres keeps its own :focus-visible so keyboard focus on the link is still visible. */
.rvres:focus-visible{outline:none;box-shadow:var(--focus)}
.rvres-n{flex:0 0 34px;text-align:right;font-family:var(--font-data);font-variant-numeric:tabular-nums;
  font-size:var(--text-base);font-weight:800;line-height:1}
.rvres-b{min-width:0;display:flex;flex-direction:column;gap:1px}
.rvres-t{font-size:var(--text-sm);font-weight:600;color:var(--text-primary);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rvres-m{font-size:var(--text-3xs);color:var(--muted)}
.rvres-m b{color:var(--status-success-text);text-transform:uppercase;letter-spacing:.04em}

/* THE REVIEW SPLIT. Fixes on the left, the document they refer to on the right, so clicking a fix
   marks something already on screen instead of something a scroll away. The document sticks. */
.rvsplit{display:grid;grid-template-columns:1fr;gap:var(--space-300)}
.rvsplit-a,.rvsplit-b{min-width:0}
@media (min-width:1400px){
  .rvsplit{grid-template-columns:minmax(0,7fr) minmax(0,8fr);gap:var(--space-400)}
  .rvsplit-b{position:sticky;top:calc(var(--topbar-h) + var(--space-200));align-self:start}
  .rvsplit-b .rv-doc{max-height:calc(100vh - var(--topbar-h) - 190px)}
  .rvdoc-h{margin-top:0}
}

/* Bigger. 12px monospace for a document you are being asked to READ was the wrong call. */
.rv-doc{font-size:var(--text-sm);line-height:1.8;padding:20px}
.rvb-text{font-size:var(--text-xs);line-height:1.7}
.rs-off li{font-size:var(--text-xs)}
.rvc-name{font-size:var(--text-xs)}
.rvcheck{font-size:var(--text-xs)}

/* Bullets in two columns on a wide screen -- each row is short, so one column wasted the width. */
@media (min-width:1500px){
  .rvblist-2{display:grid;grid-template-columns:1fr 1fr;column-gap:var(--space-400);
    align-items:start}
  .rvblist-2>li{break-inside:avoid}
}

/* The PDF, now that it renders at all. Tall enough to read a page without scrolling twice. */
.rvpdf{width:100%;height:min(86vh,1100px);border:1px solid var(--line);border-radius:var(--r-lg);
  background:var(--surface2);display:block}
.rvpdf-alt{margin-top:var(--space-100);font-size:var(--text-2xs)}
@media (max-width:640px){
  .rv-doc{font-size:var(--text-xs);padding:12px}
  .rvpdf{height:62vh}
}

/* One compact header line per stored file, replacing a borrowed .simrow list that duplicated the
   filename the viewer already shows. Name, type, size, and the one action that makes sense. */
.rvfilebar{display:flex;align-items:baseline;gap:var(--space-150);flex-wrap:wrap;
  padding:10px 0 12px;border-bottom:1px solid var(--line);margin-bottom:var(--space-200)}
.rvfilebar-n{font-weight:650;color:var(--text-primary);font-size:var(--text-sm)}
.rvfilebar-m{font-size:var(--text-2xs);color:var(--muted);font-family:var(--font-data)}
.rvfilebar-a{margin-left:auto;font-size:var(--text-2xs);font-weight:600}

/* ---- the PDF, rendered as pages instead of as a viewer ------------------------------------
   The surround is ours now: our background, our sheet, our spacing. Nothing here is fighting a
   document we do not control, which is the whole reason canvas replaced the iframe. */
.rvpdfdoc{display:flex;flex-direction:column;gap:var(--space-250);
  background:var(--surface2);border:1px solid var(--line);border-radius:var(--r-lg);
  padding:var(--space-250);max-height:min(88vh,1200px);overflow-y:auto}
/* A page reads as a sheet: white regardless of theme (paper is paper), a real shadow, and no
   radius, because a printed page does not have rounded corners. */
.rvpage{background:#fff;box-shadow:0 1px 2px rgba(16,19,25,.10),0 8px 24px rgba(16,19,25,.10);
  line-height:0}
.rvpage canvas{display:block}
.rvpdf-status{margin:0;font-size:var(--text-2xs);line-height:1.6}
/* Before the first page lands, the box should look like it is about to hold a page rather than
   like an empty panel. Removed the moment a page is drawn. */
.rvpdfdoc:not(.is-ready):not(.is-failed)::after{content:"Rendering your résumé…";
  display:block;padding:var(--space-600) 0;text-align:center;color:var(--muted);
  font-size:var(--text-2xs)}
.rvpdfdoc.is-failed{padding:var(--space-200)}
@media (max-width:640px){
  .rvpdfdoc{padding:var(--space-150);gap:var(--space-150);max-height:70vh}
}

/* ============================================================================
   THE COMPANY DIRECTORY  (/companies)
   Rebuilt 2026-08-22. What was here read as assembled rather than designed, and
   every cause was structural rather than a matter of taste:

     * .countrow's justify-content:space-between scattered ~1000px of slack into
       three ~340px gaps, because four children summing to ~800px were spread
       across an 1880px track. Fixed by packing left and giving ONE child an auto
       margin, and by giving the bar a bottom rule -- which is the mechanism that
       makes the feed's five-control .filterbar look intentional at the same
       width. Negative space inside a framed band reads as breathing room; the
       same space between unframed orphans reads as gaps.
     * .results .count{margin:0} and .results .tabs{margin-top:0} never matched,
       because this page has no .results wrapper, so .count's 16px/4px margin and
       .tabs' 16px top margin survived and stacked with .countrow's own 14px for
       ~34px of dead vertical space and a count sitting below the optical centre.
     * Three type sizes in one row: .count at 13px, the controls at 14px, and the
       .visack label at the 16px body default because it set no font-size.
     * Three control heights (7px, 9px and 8px of padding) with --field-h:40px
       sitting unused. Everything in .cobar is --field-h now.
     * A wall of 16 .tab pills, ~3,900px of pill across an ~1,828px track, so it
       wrapped to two or three rows ABOVE the search box. Now one .fchip over one
       .fpop.
     * A lone native checkbox that was class="visack" WITHOUT .ck, so it fell
       through to the global input rule (width:100%, 11px padding, 14px radius)
       and got no accent-color. It was the only unstyled control in the product.
       It is GONE, not restyled: "it looks only one" was half the complaint, so a
       single boolean became a .seg pair that states both halves.

   The CHILDREN are the feed's components (.fsearch, .fchip, .fpop, .seg, .sel,
   .roletile) so the two pages read as one product. The CONTAINER is scoped:
   .filterbar's @media (max-width:900px) turns itself into a horizontal scroller
   with .fchip-more pinned sticky-right, and this page has no overflow chip to
   pin. Six duplicated declarations, in exchange for not coupling two pages'
   narrow-width layouts to one rule.

   Column arithmetic. Content width is min(cap, vw - 52); the cap is .feedwrap's
   1880, rising to 2280 above a 1900 viewport. At T=288 and a 20px gap, allowing
   16px for a scrollbar: 1 at 375, 2 at 768, 3 at 1024, 4 at 1280, 4 at 1440,
   6 at 1920, 7 at 2560.
   THE PREVIOUS COMMENT CLAIMED 5 AT 1440 AND WAS WRONG: 5x288 + 4x16 = 1504
   against 1372 of track. It never rendered five. T stays at 288 rather than
   dropping to make the old claim true -- the tile now carries a variable-width
   logo as well, so it needs the width, and this block's own original note said
   over-densifying is the opposite failure and just as bad.

   One new token family, all neutrals off the layer-1 ramp, so the chromatic
   budget is untouched and THE COLOUR RULE still holds. Every chip here still
   reuses .vt-* / .cx / .agency.
   ============================================================================ */

/* ---- page head ---------------------------------------------------------- */
.cohead-page{margin:0 0 var(--space-200)}
.codeck{margin:var(--space-100) 0 0;font-size:var(--text-sm);
  line-height:var(--leading-sm);color:var(--text-secondary);max-width:68ch}

/* ---- the toolbar -------------------------------------------------------- */
/* position:relative is load-bearing: .fpop is position:absolute and .feedwrap
   sets no position, so without this every popover resolves against <body> and
   lands in the wrong place at any scroll offset. It fails SILENTLY at scroll
   offset 0, which is exactly where anyone testing it will look first. */
.cotools{position:relative}
.cobar{position:sticky;top:var(--topbar-h);z-index:var(--z-sticky);
  display:flex;align-items:center;gap:var(--space-150);flex-wrap:wrap;
  padding:var(--space-150) 0;margin:0 0 var(--space-150);
  background:var(--bg-canvas);border-bottom:1px solid var(--border-subtle)}
/* NEVER set justify-content here. One child takes the slack, on purpose. */
.cobar .fsearch{flex:1 1 300px;max-width:520px}
.cobar .fsearch input{width:100%;height:var(--field-h);padding-top:0;padding-bottom:0}
.cobar .sel{margin-left:auto;height:var(--field-h);padding-top:0;padding-bottom:0}
/* .seg measures ~41px (3 + 7 + a 14px line + 7 + 3) against --field-h's 40. One
   pixel is enough to make a row of controls look untuned. */
.cobar .seg{padding:2px}
.cobar .segb{display:inline-flex;align-items:center;
  height:calc(var(--field-h) - 4px);padding:0 var(--space-200);
  font-size:var(--text-xs)}

/* ---- the result line ---------------------------------------------------- */
/* The count LEFT the toolbar. It belongs beside the grid it counts, it was the
   only --text-xs item in a row of --text-sm controls, and down here there is
   room for the Clear affordance this page never had. */
.coresult{display:flex;align-items:center;gap:var(--space-050);flex-wrap:wrap;
  margin:0 0 var(--space-250);font-size:var(--text-xs);
  color:var(--text-secondary)}
.coresult b{font-family:var(--font-data);font-variant-numeric:tabular-nums;
  font-weight:var(--weight-semibold);color:var(--text-primary)}
.coresult .btn{margin-left:var(--space-100)}

/* ---- the facet list inside the popover ---------------------------------- */
/* .roletile wholesale: a 38px row, a DRAWN tick from --tick-mark, :focus-within
   and a right-aligned tabular count. Single-select here, so the tick is a ring
   rather than a box -- the one-line difference from the multi-select original.
   No @supports not selector(:has(*)) fallback is needed even though .roletile
   has none: companies.js rebuilds this list from state on every render and sets
   .on itself, so the tick never depends on :has(). welcome.html needed the
   fallback because it ships no JS on that step. */
.cofacet{display:flex;flex-direction:column;gap:var(--space-025)}
.cofacet .roletile::before{border-radius:var(--radius-full)}

/* ---- the grid ----------------------------------------------------------- */
.codir{display:grid;grid-template-columns:repeat(auto-fill,minmax(288px,1fr));
  gap:var(--space-200)}
/* Following #feed's own precedent above: in a grid this long the tiles need to
   read as separate objects. Verified to cost no column at any breakpoint. */
@media (min-width:1200px){ .codir{gap:var(--space-250)} }
.cosections{display:block}

/* ---- section headers ---------------------------------------------------- */
/* .sechdr is a 12px uppercase LABEL and is shared with /company (x10) and /job
   (x7), so it is left alone and this page stops borrowing it -- it was far too
   quiet to head a 695-tile section. "Show All" moved from a centred .morewrap
   between two grids INTO this header, right aligned: a section control rather
   than the most generic element on the page. */
.cosec-h{display:flex;align-items:baseline;gap:var(--space-150);
  margin:var(--space-500) 0 var(--space-200);padding-bottom:var(--space-100);
  border-bottom:1px solid var(--border-subtle)}
.cosec-h:first-child{margin-top:var(--space-100)}
.cosec-h h2{font-size:var(--text-md);font-weight:var(--weight-semibold);
  color:var(--text-primary);letter-spacing:var(--tracking-tight);min-width:0;
  margin:0}
.cosec-n{flex:0 0 auto;font-family:var(--font-data);font-size:var(--text-2xs);
  font-variant-numeric:tabular-nums;color:var(--text-secondary)}
.cosec-h .btn{margin-left:auto;flex:0 0 auto}

/* ---- the tile ----------------------------------------------------------- */
/* min-height is GONE. It was a magic 196 that also disagreed with .card's own
   contain-intrinsic-size of 186, which is a live 10px scrollbar jump as tiles
   are realised. A grid already stretches every item in a row to the tallest, so
   evenness comes from reserving the chip band and the link band instead. */
/* flex-direction AND padding are this tile opting OUT of the feed card's insides,
   not decoration. .card is a SHARED surface -- the feed uses it, so does .rb, so
   does this -- but f992caf gave the base class the FEED card's layout: row, so
   .cardverdict can hold the trailing edge, and padding:0, so that column reaches
   the border. A company tile is a column and owns its own inset. The feed's own
   @media (max-width:1760px) sets flex-direction:column and HID this for ten days:
   above 1760 the tile laid logo, name, count, chips and links out in a row and the
   name collapsed to 35px, so 'Amazon' read 'Ama' with 2,827 beside it. Measured at
   1759 vs 1761. display:flex still comes from .card and is REQUIRED -- .kw's
   margin-top:auto is what pins the link band to the bottom of every tile. */
.cocard{contain-intrinsic-size:auto 224px;text-decoration:none;color:inherit;
  flex-direction:column;padding:18px}
.cocard:hover{text-decoration:none}

/* THE LOGO LOCKUP. The invariant is a PLATE of fixed HEIGHT and variable width,
   never a fixed box, and every tile has one -- roughly a third of them hold two
   letters rather than an image, and that is a first-class state, not an error.

   Cap height is the constant because that is what the eye measures. A 5:1
   wordmark inside a 48x48 contain box renders at 48x9.6 and is illegible; at
   36px TALL and free to be as wide as it needs, its letterforms land at 12-14px
   and it carries the same optical weight as a 36px square mark.

   object-position:left and width:auto put every plate on one left rule down the
   column, which is what stops a grid of mixed square marks, wordmarks and
   monograms from going ragged. A centred variable-width logo would give every
   column a different optical axis, which IS the ragged failure.

   And the plate HUGS its content, never width:100%. That is also the dark-mode
   anti-glare mechanism: 24x24 is ~1% of the card, a mark rather than a light bar
   across it.

   SIZED DOWN FROM 36/180 ON 2026-08-23, with the feed. At 36 tall and 180 wide a
   wordmark was the largest thing on the tile and read as an advertisement for the
   employer rather than as a label on it -- and the tile carries the name directly
   below, so the mark never had to carry identity alone. The width cap moved with
   the height so a 5:1 wordmark stays proportionate instead of stretching to the old
   cap at the new height.

   THE BOTTOM MARGIN IS SCOPED TO THE TILE, and that is a fix, not a tidy-up. This
   rule is shared with .cohead, .jobhead and .modal-head, which are flex ROWS with
   align-items:center -- and align-items centres the MARGIN box, so 12px of bottom
   margin sat the mark ~6px above the heading's optical centre and made the row 12px
   taller than its tallest visible child. Only the tile stacks. */
.colock{display:flex;align-items:center;margin:0;min-width:0}
.cocard .colock{margin-bottom:var(--space-150)}
.colock > *{height:24px;min-width:24px;max-width:min(104px,100%);
  border-radius:var(--radius-xs)}
/* NO PLATE UNDER A LOGO IN LIGHT MODE, and the reason is that there is no single colour that
   could work. A feed card is tinted by the sponsorship route it names -- five fills, from
   #dbe7fb for H-1B through #eadffb, #d6efe0, #e7e9ee to #f6f7f9 for none -- so a fixed plate
   blends with exactly one of them and is a visible rectangle on the other four. The first
   attempt at this set the plate to #f6f7f9, which is --route-none-fill to the byte: it looked
   perfect on an untinted card and stayed a white box on every tinted one.
   Transparent is the only value that blends with all five, and every light fill is light
   enough to carry a dark or full-colour mark unaided. */
/* Padding came down with the box. box-sizing is border-box here, so at 3px/5px the plate
   ate a quarter of a 24px lockup and left 18px of artwork; at 2px/3px it leaves 20 of 24,
   the same 83% the old 30-of-36 gave. It is not decoration -- it is what stops a logo with
   white corners sitting flush against the dark-mode plate edge. */
.colock img{display:block;width:auto;object-fit:contain;object-position:left center;
  padding:2px 3px;background:transparent}
/* Dark mode needs SOMETHING behind a dark-ink mark: the five route fills are #18253f..#171a22
   there, and most brand logos are drawn in dark ink for white paper. A light plate is a visible
   rectangle on a dark card, so it is now the FALLBACK rather than the rule. */
[data-theme="dark"] .colock img{background:var(--logo-plate);
  box-shadow:inset 0 0 0 1px var(--logo-plate-edge)}
/* A SINGLE-INK MARK IS INVERTED INSTEAD OF PLATED. A monochrome dark logo carries no brand
   colour to protect, so flipping it to light ink loses nothing and it can then sit straight on
   the card -- no plate, no rectangle, which is the whole complaint. data-mono comes from the
   harvest manifest and means "one DARK ink": judge() composites onto white and rejects a blank,
   so an accepted raster mono logo cannot be a white knockout, and svg_meta reads the ink's
   luminance for the one source that composites nothing. Get that backwards and invert() paints
   a white wordmark black on a dark card.
   Full-colour logos keep the plate above -- inverting those would hue-shift the brand. */
[data-theme="dark"] .colock img[data-mono="1"]{background:transparent;box-shadow:none;
  filter:invert(1) brightness(1.9)}
/* Two letters in the data face: an initialism is a LABEL, not prose, and the
   mono setting makes it read as a deliberate stand-in rather than as a failed
   logo -- the same argument that face already wins for counts and dates.
   tabular-nums so "10x Genomics" sets evenly. Both the ink and the plate are
   mode-stable, so this is 9.2:1 in either theme. */
/* THE MONOGRAM KEEPS ITS PLATE IN BOTH THEMES, unlike the logo above. It is TEXT, and text
   needs a surface whose contrast can be asserted -- --logo-plate-ink on --logo-plate is a
   checked pair in test_contrast. Dropped onto a route tint instead it would be #3c414d on
   #18253f in dark, which is unreadable. */
.comono{flex:0 0 auto;display:flex;align-items:center;justify-content:center;
  background:var(--logo-plate);box-shadow:inset 0 0 0 1px var(--logo-plate-edge);
  width:24px;font-family:var(--font-data);font-size:var(--text-3xs);
  font-weight:var(--weight-semibold);letter-spacing:.03em;
  color:var(--logo-plate-ink);text-transform:uppercase;
  font-variant-numeric:tabular-nums;user-select:none}
/* A PAGE header carries a bigger plate than a card does. These replace the old
   .cohead/.jobhead/.modal-head .logo overrides, which pointed at a class nothing
   renders any more. Width follows height, so a wordmark still hugs. */
.cohead .colock > *,.jobhead .colock > *{height:40px;min-width:40px;max-width:min(200px,100%)}
.modal-head .colock > *{height:32px;min-width:32px;max-width:min(140px,100%)}
.cohead .comono,.jobhead .comono{width:40px;font-size:var(--text-sm)}
.modal-head .comono{width:32px;font-size:var(--text-2xs)}

.cocard h3{font-size:var(--text-base);font-weight:var(--weight-semibold);
  margin:0 0 var(--space-050);line-height:1.25;display:-webkit-box;
  -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
/* The name is a real link so the tile is keyboard-reachable, but it must read as
   INK: without this it takes the accent, which is 3.42:1 on a card and spends
   colour on something that is not sponsorship. */
.cocard h3 a{color:inherit;text-decoration:none}
.cocard:hover h3 a{text-decoration:underline;text-underline-offset:2px}
.cocard .coline{font-size:var(--text-sm);line-height:1.45;color:var(--muted)}
.cocard .conum{font-family:var(--font-data);font-variant-numeric:tabular-nums;
  color:var(--ink);font-weight:var(--weight-semibold)}
/* The sector, in the FLAT view only -- in the grouped view the header two inches
   up already says it. overflow:hidden and min-width:0 are not cosmetic: a grid
   item's automatic minimum size is its min-content width, and an unbreakable
   nowrap run of "Engineering, Construction & Real Estate" measures ~230px, which
   is how a grid track blows past its container. */
.cocard .cosector{margin-top:var(--space-025);font-size:var(--text-2xs);
  line-height:var(--leading-2xs);color:var(--text-tertiary);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
/* The auto margin moved UP one level, off .colinks. It used to sit on the links,
   so the slack opened ABOVE them and the chip row floated at a different height
   on every card in a row. Here the bottom two bands lock and the name block
   floats from the top, which is where it should float from.
   min-height because 818 of 2,695 employers (30%) carry no visa bits at all, so
   without it a third of the grid has no chip band and the row reads ragged. */
.cocard .kw{margin:var(--space-100) 0 0;margin-top:auto;min-height:21px}
.cocard .colinks{margin-top:var(--space-150);padding-top:var(--space-150);
  border-top:1px solid color-mix(in srgb,var(--text-primary) 12%,transparent);
  display:flex;gap:var(--space-100);flex-wrap:wrap}

@media (max-width:900px){
  /* Deliberately NOT .filterbar's overflow-x scroller: there is no .fchip-more
     to pin sticky-right here, so the honest narrow shape is stacked. */
  .cobar .fsearch{flex:1 0 100%;max-width:none}
  .cobar .sel{margin-left:auto}
}
@media (max-width:560px){
  .codir{grid-template-columns:1fr}
  .cocard{contain-intrinsic-size:auto 200px}
  /* Full width rather than stranded and right-aligned on its own wrapped row. */
  .cobar .sel{margin-left:0;flex:1 1 100%}
  /* A reserved band that nothing fills is a hole at 320px of card width. */
  .cocard .kw{min-height:0}
}
