/* ==========================================================================
   Skillprint design system

   Two colours carry meaning and are never used decoratively:
     teal  — a skill the candidate has, a requirement that is met
     clay  — a gap, a requirement that is missing
   Everything else is neutral, so the eye goes straight to the fit.

   Layout uses CSS logical properties throughout (inline-start, margin-inline,
   padding-block) so the whole interface mirrors correctly when `dir` flips to
   rtl. There is not a single `left` or `right` in this file.
   ========================================================================== */

:root {
  /* --- Meaning --- */
  --teal-900: #06413b;
  --teal-700: #0d6e63;
  --teal-500: #14958a;
  --teal-100: #d7ece9;
  --teal-050: #eef7f5;

  --clay-700: #9a5237;
  --clay-500: #b4694a;
  --clay-100: #f3e0d7;
  --clay-050: #faf0eb;

  /* --- Neutrals --- */
  --ink-900: #16211f;
  --ink-700: #38443f;
  /* Secondary text. Was #64726c, which cleared 4.5:1 on white (5.04) and on
     nothing else: 4.50 on clay-050, 3.95 on clay-100, 4.10 on teal-100 — all
     below AA, and 4.51/4.62/4.76 on the rest, which is the same failure waiting
     for a rounding difference.
     axe found one of those, on .card--danger .card__hint, because that is the
     only combination a visited route happens to render. Measuring the token
     against every surface in this file found the other five, so the token
     moved rather than the one rule that got caught.
     #55635d is the lightest value clearing 4.5:1 on *all seven* surfaces, worst
     case 4.94 on clay-100. It stays plainly lighter than --ink-700, which is
     the job this token has. */
  --ink-500: #55635d;
  /* 2.54:1 on white and 1.99:1 on clay-100 — far under AA, so this token may
     never carry text a person has to read. It used to: the lens tally, the
     remove glyph in the requirement editor, and every input placeholder. All
     three moved to --ink-500.
     What is left is decoration and non-text state: the "·" between job facts,
     and two hover borders. Those are the only places it belongs. */
  --ink-300: #9aa5a0;
  --line: #e2e0da;
  --line-soft: #eeece7;
  --paper: #faf8f5;
  --surface: #ffffff;
  --surface-sunk: #f4f2ee;

  /* Decoration only. 2.97:1 on white and 2.62:1 on --paper-tint, so it may
     never carry a score or any other text — see --amber-700 below. It is left
     defined because .meter__fill--semantic is a filled bar, not type. */
  --amber-500: #c68a2e;
  /* The middle score band, as text. Sized against the five grounds a score can
     land on plus its own card bloom; the worst is 4.85:1 on that bloom.
     #946420 was the first value to clear 4.5 — by 0.01 — which is the same
     "exactly at the threshold" position that made axe reject --ink-500's old
     value on a rounding difference. This one keeps a real margin. */
  --amber-700: #8d5f1a;

  /* --- The expressive layer -------------------------------------------------
   *
   * A second, deliberately louder palette that exists alongside the semantic one
   * rather than replacing it.
   *
   * WHY BOTH, instead of simply making the teals bolder. The semantic colours
   * above carry meaning that other code reasons about: --teal-500 is "a strong
   * fit", --clay-500 is "a weak one", and the lens families are checked against
   * all of them for CIE76 separation so two professions never look alike.
   * lens-palette.test.js holds a copy of those exact hex values, so raising
   * their saturation would either break that test or, worse, leave it guarding
   * a colour the product no longer uses.
   *
   * These tokens are under no such obligation because NONE OF THEM EVER CARRIES
   * TEXT. They are spokes, glows, gradients and swatches — shapes on a dark
   * ground, where saturation is free and contrast is not a question a reader's
   * eyes have to answer. That separation is the whole trick: the identity gets
   * to be loud precisely because the parts a person must read stayed pinned.
   */
  --fp-1: #2ee6a8;
  --fp-2: #35d6e8;
  --fp-3: #6b8afd;
  --fp-4: #a97bf5;
  --fp-5: #f57bb8;
  --fp-6: #ffc46b;

  /* The hero ground. Deep enough that every ramp colour above sits at a large
     contrast against it, which is what makes the figure read as luminous
     rather than merely coloured. */
  --hero-ink: #062825;
  --hero-ink-2: #0a3b45;
  /* Two lights, not three, and much softer than they were. Against the old
     teal-black these read as atmosphere; against the near-black the band uses
     now they read as a green blob over one half of it, which made every hero
     look lopsided in a screenshot. Same argument as the sign-in panel's glow:
     one light is a spotlight, two are a room. */
  --hero-glow: radial-gradient(58% 80% at 20% 16%, rgba(46, 230, 168, .15), transparent 68%),
               radial-gradient(50% 66% at 88% 86%, rgba(107, 138, 253, .13), transparent 70%);

  /* --- Type --- */
  --font-display: "Space Grotesk", "IBM Plex Sans Arabic", system-ui, sans-serif;
  --font-body: "IBM Plex Sans", "IBM Plex Sans Arabic", system-ui, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, "Courier New", monospace;

  /* --- Space & shape --- */
  /* Shape and depth, refreshed.
   *
   * Two changes, both about looking current rather than about taste for its own
   * sake. Radii step up — 6/10/16 reads as 2019, and the larger values sit
   * better against the generous line height this type already has. And the
   * shadows go from "a card floating on grey" to a hairline border doing the
   * separating with light shadow only for genuine elevation, which is where
   * interface design has settled and, incidentally, is kinder to the eye on a
   * warm paper background than a grey blur is.
   *
   * Deliberately NOT changed: any text colour token. Those are pinned by
   * measurement — see the note on --ink-500 above — and "modernising" a palette
   * by lightening its greys is the single most common way a redesign quietly
   * fails WCAG. frontend/tests/lens-palette.test.js checks every text token
   * against all seven surfaces on every run, so that mistake cannot ship here;
   * this comment is so nobody wastes an afternoon discovering why. */
  --r-sm: 8px;
  --r-md: 14px;
  --r-lg: 20px;
  --r-pill: 999px;

  --shadow-sm: 0 1px 2px rgba(22, 33, 31, .04);
  --shadow-md: 0 1px 3px rgba(22, 33, 31, .05), 0 12px 32px -8px rgba(22, 33, 31, .10);

  --tap: 44px;
}

/* Arabic sets the body font to the Arabic-optimized cut of the same family, so
   the two languages keep the same texture instead of looking like two sites. */
html[lang="ar"] {
  --font-body: "IBM Plex Sans Arabic", "IBM Plex Sans", system-ui, sans-serif;
  --font-display: "IBM Plex Sans Arabic", "Space Grotesk", system-ui, sans-serif;
}

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

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink-900);
  font-family: var(--font-body);
  font-size: 15px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* A wider type scale, which is most of what reads as "current".
 *
 * The old scale ran 1.75 / 1.3 / 1.05rem — three sizes close enough together
 * that a page had no clear entry point and everything arrived at once. Pulling
 * the top of the scale up and tightening its tracking gives a page somewhere to
 * start, and costs nothing: the sizes below it are unchanged, so no body text
 * moves and no line-length assumption breaks.
 *
 * `text-wrap: balance` on headings only — it is cheap on a two-line title and
 * expensive on a paragraph, and browsers without it simply wrap as before. */
h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.021em;
  text-wrap: balance;
  margin: 0;
}

h1 { font-size: 2.15rem; letter-spacing: -0.028em; }
h2 { font-size: 1.4rem; }
h3 { font-size: 1.05rem; letter-spacing: -0.012em; }

/* The phone gets a smaller top of the scale. 2.15rem over a 360px column is a
 * three-line title, and mobile.spec.js measures overflow rather than taste, so
 * this is the kind of thing only a screenshot catches. */
@media (max-width: 480px) {
  h1 { font-size: 1.7rem; }
  h2 { font-size: 1.2rem; }
}

p { margin: 0 0 .75rem; }
a { color: var(--teal-700); }

/* Numbers always use the mono cut, and always read left-to-right even in RTL. */
.num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  direction: ltr;
  unicode-bidi: isolate;
}

.skip-link {
  position: absolute;
  inset-block-start: -100px;
  inset-inline-start: 8px;
  z-index: 100;
  padding: .5rem .9rem;
  background: var(--teal-700);
  color: #fff;
  border-radius: var(--r-sm);
  text-decoration: none;
}
.skip-link:focus { inset-block-start: 8px; }

:focus-visible {
  outline: 2px solid var(--teal-500);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

[hidden] { display: none !important; }

/* ==========================================================================
   Top bar
   ========================================================================== */
.topbar {
  position: sticky;
  inset-block-start: 0;
  z-index: 40;
  background: rgba(250, 248, 245, .85);
  backdrop-filter: blur(10px);
  border-block-end: 1px solid var(--line);
}

.topbar__inner {
  max-width: 1180px;
  margin-inline: auto;
  padding: .6rem 1.15rem;
  display: flex;
  align-items: center;
  gap: 1.1rem;
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  text-decoration: none;
  color: var(--ink-900);
  flex-shrink: 0;
}

/* The mark is a fingerprint-ish set of arcs — drawn, not imported. */
.brand__mark {
  inline-size: 22px;
  block-size: 22px;
  border-radius: 50%;
  border: 2px solid var(--teal-700);
  border-block-start-color: transparent;
  border-inline-end-color: var(--clay-500);
  transform: rotate(-25deg);
}

.brand__name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.05rem;
  letter-spacing: -0.02em;
  direction: ltr;
}

.topnav {
  display: flex;
  gap: .2rem;
  margin-inline-end: auto;
  flex-wrap: wrap;
}

.topnav a {
  padding: .4rem .7rem;
  border-radius: var(--r-sm);
  text-decoration: none;
  color: var(--ink-700);
  font-weight: 500;
  font-size: .92rem;
}
.topnav a:hover { background: var(--surface-sunk); }
.topnav a[aria-current="page"] {
  background: var(--teal-050);
  color: var(--teal-900);
}

.topbar__actions {
  display: flex;
  align-items: center;
  gap: .5rem;
  flex-shrink: 0;
}

.usermenu {
  display: flex;
  align-items: center;
  gap: .5rem;
  font-size: .85rem;
  color: var(--ink-500);
}

.avatar {
  inline-size: 30px;
  block-size: 30px;
  border-radius: 50%;
  background: var(--teal-100);
  color: var(--teal-900);
  display: grid;
  place-items: center;
  font-weight: 600;
  font-size: .8rem;
  flex-shrink: 0;
}

/* ==========================================================================
   Layout
   ========================================================================== */
.main {
  max-width: 1180px;
  margin-inline: auto;
  padding: 1.6rem 1.15rem 4rem;
}
.main:focus { outline: none; }

.page-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  margin-block-end: 1.25rem;
}

.page-head__sub {
  color: var(--ink-500);
  font-size: .92rem;
  margin: .25rem 0 0;
}

.grid { display: grid; gap: 1rem; }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.grid--sidebar { grid-template-columns: minmax(0, 1fr); }

@media (min-width: 900px) {
  .grid--sidebar { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }

  /* The side column follows the reader down the page.
     Measured, because "it looks a bit empty" is not a reason to change a
     layout: on #/me the side column is 835px tall inside a 2670px page, so
     roughly 1830px of it — more than two thirds — was blank. Everything in it
     is an action on the CV being read beside it (re-analyse, download,
     "let employers find me", replace the file), and all four scrolled out of
     reach within one screen of a page that runs for eight.
     Sticky rather than fixed so it never leaves its own column, and offset by
     the topbar's own height (54px, measured) plus a gap, or it would slide
     under the bar it shares the top of the viewport with.
     Only above 900px. Below that the grid is one column and the side content
     sits after the list, where sticking it would pin a block over the very
     thing the reader scrolled to. */
  /* `:last-child`, not `> .stack`. Both screens that use this grid put a
     `.stack` in the side column, but the job-detail screen also uses one for
     its MAIN column — so `> .stack` would have pinned the article a reader is
     scrolling through. The side column is the last of the two, on both. */
  .grid--sidebar > :last-child {
    position: sticky;
    inset-block-start: calc(54px + 1.25rem);
    /* A safety valve: if the column ever grows taller than the viewport, it
       scrolls within itself instead of hiding its own last card forever. */
    max-block-size: calc(100vh - 54px - 2.5rem);
    overflow-y: auto;
  }
}

.stack { display: grid; gap: .75rem; }
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
.row--between { justify-content: space-between; }
.spacer { flex: 1 1 auto; }

/* ==========================================================================
   Cards
   ========================================================================== */
.card {
  background: var(--surface);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-lg);
  padding: 1.35rem;
  box-shadow: var(--shadow-sm);
}

.card--flush { padding: 0; overflow: hidden; }
.card--sunk { background: var(--surface-sunk); box-shadow: none; }

.card__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .75rem;
  margin-block-end: .85rem;
}

.card__title { font-size: 1.02rem; }

.card__hint {
  color: var(--ink-500);
  font-size: .85rem;
  margin: 0;
}

/* Years of experience and seniority, above the skill list. */
.factline {
  padding-block-end: .9rem;
  margin-block-end: .9rem;
  border-block-end: 1px solid var(--line);
}

.factline__row {
  display: flex;
  align-items: center;
  gap: .6rem;
  margin-block-end: .3rem;
}

.factline__value {
  font-size: 1.05rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* A job card that is also a link */
.joblist { display: grid; gap: .7rem; }

.jobcard {
  display: flex;
  /* The employer's card carries three children — body, status pill, and the
     "Candidates" button — and neither the pill nor the button can shrink. At
     360px that pushed the document to 388px and scrolled the whole page
     sideways. Wrapping rather than a breakpoint: the body's flex-basis below
     decides when there is no longer room, so the same rule holds at every
     width instead of at the widths somebody remembered to write down. */
  flex-wrap: wrap;
  gap: 1rem;
  align-items: center;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: .95rem 1.1rem;
  text-decoration: none;
  color: inherit;
  transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease;
}
.jobcard:hover {
  border-color: var(--teal-700);
  transform: translateY(-1px);
  box-shadow: var(--shadow-md);
}

/* 12rem is the width below which the title stops being a title and starts
   being one word per line. Reaching it is what makes the row wrap, and
   min-inline-size:0 keeps a long unbroken title from setting the floor itself. */
.jobcard__body { min-inline-size: 0; flex: 1 1 12rem; }
.jobcard__title { font-size: 1rem; margin-block-end: .15rem; }
.jobcard__meta {
  color: var(--ink-500);
  font-size: .85rem;
  display: flex;
  gap: .5rem;
  flex-wrap: wrap;
}
.jobcard__meta span { white-space: nowrap; }
.jobcard__meta span + span::before {
  content: "·";
  margin-inline-end: .5rem;
  color: var(--ink-300);
}

/* The separator is a *between* mark, and CSS cannot see where a line breaks.
   Once the meta wraps, every item after the first begins its line with a
   dangling "·" — which is what the 360px screenshot showed, and what no
   overflow assertion could ever have caught.
   So below the width where these facts stop fitting on one line, they become a
   stacked list and the separator goes. 480px approximates "this will wrap"; the
   exact condition depends on the content, and CSS has no way to ask.

   max-width, not max-inline-size. This file uses logical *properties*
   throughout and has no `left` or `right` in it, and I carried that convention
   into a media *feature*, where the logical forms are not supported anywhere.
   The rule simply never matched — silently, which is the only way a media query
   can fail. */
@media (max-width: 480px) {
  .jobcard__meta { flex-direction: column; gap: .15rem; }
  .jobcard__meta span + span::before { content: none; }
}

/* ==========================================================================
   The match gauge

   An SVG ring whose stroke animates from zero on mount. The number inside is
   the only place in the interface that uses display-size mono type — it is the
   answer the whole product exists to give.
   ========================================================================== */
.gauge {
  position: relative;
  inline-size: var(--gauge-size, 108px);
  block-size: var(--gauge-size, 108px);
  flex-shrink: 0;
}

.gauge svg {
  inline-size: 100%;
  block-size: 100%;
  transform: rotate(-90deg);
}

.gauge__track {
  fill: none;
  stroke: var(--line-soft);
  stroke-width: var(--gauge-stroke, 9);
}

.gauge__value {
  fill: none;
  stroke: var(--gauge-color, var(--teal-700));
  stroke-width: var(--gauge-stroke, 9);
  stroke-linecap: round;
  stroke-dasharray: var(--gauge-circumference) var(--gauge-circumference);
  stroke-dashoffset: var(--gauge-circumference);
  transition: stroke-dashoffset 1.1s cubic-bezier(.22, .8, .3, 1);
}

.gauge.is-filled .gauge__value { stroke-dashoffset: var(--gauge-offset); }

.gauge__label {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  text-align: center;
  gap: 0;
}

.gauge__number {
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: calc(var(--gauge-size, 108px) * .27);
  line-height: 1;
  color: var(--gauge-color, var(--teal-900));
  direction: ltr;
}

.gauge__unit {
  font-size: calc(var(--gauge-size, 108px) * .13);
  color: var(--ink-500);
  font-family: var(--font-mono);
}

.gauge--sm { --gauge-size: 66px; --gauge-stroke: 7; }
.gauge--lg { --gauge-size: 152px; --gauge-stroke: 11; }

@media (prefers-reduced-motion: reduce) {
  .gauge__value { transition: none; }
}

/* ==========================================================================
   Skill chips — teal for held, clay for missing
   ========================================================================== */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  padding: .25rem .6rem;
  border-radius: var(--r-pill);
  font-size: .82rem;
  font-weight: 500;
  border: 1px solid transparent;
  max-inline-size: 100%;
}

.chip__text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* A CHIP ON A CARD IS AN OUTLINE, NOT A FILL, and the reason is arithmetic.
 *
 * These three used to be pale tints — teal-050, clay-050, a cream — and the
 * card beneath them carries a wash mixed from those same meaning colours. So a
 * card could cancel its own chips. Measured: a gap chip sat ΔE 3.00 from a mid
 * card and 3.44 from a weak one, and 2.3 is the point at which two colours stop
 * being distinguishable at all. It failed worst exactly where it mattered most
 * — the weak and middling cards, whose gaps are the ones worth reading.
 *
 * The obvious repair was to darken the tints, and it does not work. A teal chip
 * one step darker landed ΔE 2.51 from a strong card, which is further inside
 * the same hole. That is not a tuning problem: ANY fixed fill collides with
 * some wash, because a light fill vanishes on a pale band and a dark one on a
 * deep band, and the bands span both. There is no fill that survives all of
 * them, so the fill had to go.
 *
 * What is left is a border, and a border is measured against its ground by
 * contrast rather than by ΔE — the threshold this file already uses for a line
 * that has to stay visible. Held is a solid teal outline, near a solid amber
 * one, missing a DASHED clay one: hue says which meaning, the dash says absent,
 * and a wash can cancel neither.
 *
 * It also costs a fraction of the ink. Eight filled chips were the loudest
 * thing in the feed and the least useful at a glance; eight outlines let the
 * score and the title lead, which is the order the page is for. */
.chip--have {
  background: transparent;
  color: var(--teal-900);
  border-color: var(--teal-700);
}

.chip--gap {
  background: transparent;
  color: var(--clay-700);
  border-color: var(--clay-700);
  /* The one chip drawn with a broken line, because it is the one naming
     something that is not there. */
  border-style: dashed;
}

.chip--near {
  background: transparent;
  color: #7a5410;
  border-color: var(--amber-700);
}

.chip--neutral {
  background: var(--surface-sunk);
  color: var(--ink-700);
  border-color: var(--line);
}

/* A dot that says how the skill was found, without needing a legend. */
.chip__dot {
  inline-size: 6px;
  block-size: 6px;
  border-radius: 50%;
  background: currentColor;
  opacity: .55;
  flex-shrink: 0;
}
.chip__dot--hollow {
  background: transparent;
  border: 1.5px solid currentColor;
  opacity: .7;
}

.chip--required { font-weight: 600; }
.chip--required::after {
  content: "*";
  color: var(--clay-500);
  font-weight: 700;
}

/* Confidence bar inside the fingerprint list */
/* The evidence row, rebuilt around what it is for.
 *
 * This page's promise is "the skills we read from your CV, and exactly where we
 * found them", and the evidence line is the whole of that promise. It was
 * failing at it in a way that looked like styling and was not.
 *
 * Measured before the change: 26 skills, each carrying a three-line excerpt of
 * 180 characters, made #/me 3589px tall on a desktop and 6309px on a phone. And
 * because the excerpt is a fixed window either side of the match, consecutive
 * skills from the same dense line of a CV — "Languages: Python, SQL, JS,
 * TypeScript" — produce almost the same 180 characters, so the reader sees the
 * same paragraph 26 times and cannot see the matched word in any of them.
 *
 * So: one line, clamped, quieter than the skill name, with the matched term
 * marked. The excerpt stops being a wall and becomes what it claimed to be —
 * a pointer into the CV. Nothing about the extraction changed; this is the
 * same data, shown at the size it deserves.
 */
.skillrow {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: .1rem .9rem;
  align-items: baseline;
  padding: .5rem 0;
  border-block-end: 1px solid var(--line-soft);
}
.skillrow:last-child { border-block-end: 0; }

.skillrow__name {
  font-weight: 550;
  letter-spacing: -.005em;
}

.skillrow__evidence {
  grid-column: 1 / -1;
  color: var(--ink-500);
  font-size: .8rem;
  line-height: 1.5;
  /* One line. The excerpt is a pointer, not a passage: a reader who wants the
     surrounding paragraph has the CV itself, and 26 passages are unreadable
     however they are typeset. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin-block-start: .1rem;
}

/* The matched term, which was invisible before.
 *
 * The excerpt is a window centred on the match, so the word was always in the
 * middle of it — and with 180 characters of identical-looking context either
 * side, nobody could find it. Marking it is the difference between "here is
 * some text from your CV" and "here is where we read this".
 *
 * A tinted background rather than bold: bold at .8rem against --ink-500 is
 * barely a signal, and the tint reads at a glance while scanning a column.
 * `color: inherit` because the default UA mark is black on yellow, which would
 * be the loudest thing on the page. */
.skillrow__evidence mark {
  background: var(--teal-100);
  color: var(--ink-900);
  border-radius: 3px;
  padding: .05em .25em;
  font-weight: 550;
}

.meter {
  inline-size: 72px;
  block-size: 5px;
  background: var(--line-soft);
  border-radius: var(--r-pill);
  overflow: hidden;
  transform: translateY(-1px);
}
.meter__fill {
  block-size: 100%;
  background: var(--teal-500);
  border-radius: inherit;
}
.meter__fill--semantic { background: var(--amber-500); }

/* ==========================================================================
   Forms
   ========================================================================== */
.field { display: grid; gap: .3rem; margin-block-end: .85rem; }

.label {
  font-size: .86rem;
  font-weight: 500;
  color: var(--ink-700);
}

.input, .textarea, .select {
  font: inherit;
  font-size: .95rem;
  color: var(--ink-900);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: .6rem .7rem;
  min-block-size: var(--tap);
  inline-size: 100%;
  transition: border-color .15s ease, box-shadow .15s ease;
}

.textarea { min-block-size: 190px; resize: vertical; line-height: 1.65; }

.input:focus, .textarea:focus, .select:focus {
  outline: none;
  border-color: var(--teal-700);
  box-shadow: 0 0 0 3px var(--teal-050);
}

.input::placeholder, .textarea::placeholder { color: var(--ink-500); }

.hint { font-size: .8rem; color: var(--ink-500); }
.error-text { font-size: .84rem; color: var(--clay-700); }

.btn {
  font: inherit;
  font-weight: 500;
  font-size: .92rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .4rem;
  min-block-size: var(--tap);
  padding: .5rem 1.05rem;
  border-radius: var(--r-md);
  border: 1px solid transparent;
  cursor: pointer;
  text-decoration: none;
  transition: background .15s ease, border-color .15s ease, opacity .15s ease;
}

.btn--primary { background: var(--teal-700); color: #fff; }
.btn--primary:hover:not(:disabled) { background: var(--teal-900); }

.btn--secondary {
  background: var(--surface);
  color: var(--ink-900);
  border-color: var(--line);
}
.btn--secondary:hover:not(:disabled) { border-color: var(--ink-300); }

.btn--ghost { background: transparent; color: var(--ink-700); }
.btn--ghost:hover:not(:disabled) { background: var(--surface-sunk); }

.btn--danger { background: transparent; color: var(--clay-700); border-color: var(--clay-100); }
.btn--danger:hover:not(:disabled) { background: var(--clay-050); }

.btn--sm { min-block-size: 34px; padding: .25rem .65rem; font-size: .85rem; }

.btn:disabled { opacity: .5; cursor: not-allowed; }

.btn--block { inline-size: 100%; }

/* File drop zone */
.dropzone {
  border: 1.5px dashed var(--line);
  border-radius: var(--r-lg);
  background: var(--surface-sunk);
  padding: 1.6rem 1.1rem;
  text-align: center;
  cursor: pointer;
  transition: border-color .15s ease, background .15s ease;
}
.dropzone:hover, .dropzone.is-over {
  border-color: var(--teal-700);
  background: var(--teal-050);
}
.dropzone input[type="file"] { display: none; }
.dropzone__title { font-weight: 600; margin-block-end: .2rem; }
.dropzone__hint { color: var(--ink-500); font-size: .85rem; margin: 0; }

/* ==========================================================================
   Tables (employer candidate list)
   ========================================================================== */
.table-wrap { overflow-x: auto; }

.table {
  inline-size: 100%;
  border-collapse: collapse;
  font-size: .9rem;
}

.table th, .table td {
  padding: .7rem .8rem;
  text-align: start;
  border-block-end: 1px solid var(--line-soft);
  vertical-align: middle;
}

.table th {
  font-size: .78rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--ink-500);
  font-weight: 600;
  background: var(--surface-sunk);
  white-space: nowrap;
}

.table tbody tr:hover { background: var(--teal-050); }

/* .score-cell and its three colour rules stood here. They were the only thing
   that ever rendered the score colours as flat text, on the employer's
   applicant and sourcing tables — and both of those are card lists now, where
   the gauge carries the number. The rules had no remaining call site, and CSS
   nothing applies is worse than clutter here: lens-palette.test.js was reading
   these very declarations to check that a card and its number agree. It reads
   SCORE_COLORS in js/ui.js instead, which is where that colour now comes from.

   .table above is kept deliberately. Nothing renders it today either, but it
   is a generic utility with no guard pointed at it, and the CSV and PDF
   exports mean tabular candidate data has not left the product. */

/* ==========================================================================
   Status pills, stats, misc
   ========================================================================== */
.pill {
  display: inline-flex;
  align-items: center;
  gap: .3rem;
  padding: .15rem .55rem;
  border-radius: var(--r-pill);
  font-size: .76rem;
  font-weight: 600;
  background: var(--surface-sunk);
  color: var(--ink-700);
  white-space: nowrap;
}
.pill--ok { background: var(--teal-050); color: var(--teal-900); }
.pill--warn { background: #fdf6e7; color: #7a5410; }
.pill--bad { background: var(--clay-050); color: var(--clay-700); }

.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
  gap: .7rem;
}

.stat {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: .75rem .85rem;
}
.stat__value {
  font-family: var(--font-mono);
  font-size: 1.45rem;
  font-weight: 600;
  line-height: 1.1;
  direction: ltr;
}
.stat__label { font-size: .8rem; color: var(--ink-500); }

.empty {
  text-align: center;
  padding: 2.5rem 1rem;
  color: var(--ink-500);
}
.empty__title { color: var(--ink-900); margin-block-end: .35rem; }

.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .6rem;
  padding: 2.5rem;
  color: var(--ink-500);
}

.loading__pulse {
  inline-size: 12px;
  block-size: 12px;
  border-radius: 50%;
  background: var(--teal-500);
  animation: pulse 1.1s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { transform: scale(.7); opacity: .4; }
  50% { transform: scale(1); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .loading__pulse { animation: none; }
}

/* ==========================================================================
   Auth screen
   ========================================================================== */
.auth {
  min-block-size: 78vh;
  display: grid;
  place-items: center;
  padding-block: 2rem;
}

.auth__card {
  inline-size: min(430px, 100%);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 1.7rem;
  box-shadow: var(--shadow-md);
}

.auth__lede {
  color: var(--ink-500);
  font-size: .92rem;
  margin-block-end: 1.25rem;
}

.tabs {
  display: flex;
  gap: .25rem;
  background: var(--surface-sunk);
  padding: .25rem;
  border-radius: var(--r-md);
  margin-block-end: 1.25rem;
  /* Four tabs whose labels are single long words cannot shrink below their
     min-content, and at 360px they pushed the document 6px past the viewport —
     which scrolls the *page*, so the heading and the applicant list drift too.
     A tab bar that scrolls within itself is the right answer to more tabs than
     fit; a page that scrolls sideways never is. */
  overflow-x: auto;
  /* The bar is a strip of colour, so a scrollbar drawn inside it reads as a
     second border. The tabs remain reachable by swipe, wheel, and keyboard. */
  scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }

/* Anchors get the same treatment as buttons: the employer's applicants/sourcing
   pair are two routes, so they must be real links — deep-linkable, and openable
   in a new tab — while still reading as one control. */
.tabs button, .tabs a {
  /* Grow to fill the bar when there is room; never shrink a label into a
     column of letters when there is not. That pairing is what makes the
     container scroll instead of the page. */
  flex: 1 0 auto;
  white-space: nowrap;
  font: inherit;
  font-weight: 500;
  font-size: .9rem;
  border: 0;
  background: transparent;
  color: var(--ink-500);
  padding: .5rem;
  border-radius: var(--r-sm);
  cursor: pointer;
  min-block-size: 38px;
}
.tabs a {
  display: flex;
  align-items: center;
  justify-content: center;
  text-decoration: none;
}
.tabs button[aria-selected="true"], .tabs a[aria-current="page"] {
  background: var(--surface);
  color: var(--ink-900);
  box-shadow: var(--shadow-sm);
}

.rolepick { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; }

.rolepick label {
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: .65rem;
  text-align: center;
  cursor: pointer;
  font-size: .88rem;
  font-weight: 500;
  background: var(--surface);
}
.rolepick input { position: absolute; opacity: 0; pointer-events: none; }
.rolepick input:checked + span { color: var(--teal-900); }
.rolepick label:has(input:checked) {
  border-color: var(--teal-700);
  background: var(--teal-050);
}
.rolepick label:has(input:focus-visible) { outline: 2px solid var(--teal-500); outline-offset: 2px; }

/* ==========================================================================
   Toasts
   ========================================================================== */
.toaster {
  position: fixed;
  inset-block-end: 1rem;
  inset-inline-start: 1rem;
  z-index: 90;
  display: grid;
  gap: .5rem;
  max-inline-size: min(360px, calc(100vw - 2rem));
}

.toast {
  background: var(--ink-900);
  color: #fff;
  padding: .7rem .9rem;
  border-radius: var(--r-md);
  font-size: .89rem;
  box-shadow: var(--shadow-md);
  animation: toast-in .2s ease-out;
}
.toast--error { background: var(--clay-700); }
.toast--ok { background: var(--teal-900); }

@keyframes toast-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .toast { animation: none; }
}

/* ==========================================================================
   Match breakdown legend
   ========================================================================== */
.legend {
  display: flex;
  flex-wrap: wrap;
  gap: .9rem;
  font-size: .8rem;
  color: var(--ink-500);
  padding-block-start: .6rem;
  border-block-start: 1px solid var(--line-soft);
  margin-block-start: .9rem;
}

.legend__item { display: inline-flex; align-items: center; gap: .35rem; }

.legend__swatch {
  inline-size: 10px;
  block-size: 10px;
  border-radius: 3px;
}
.legend__swatch--have { background: var(--teal-500); }
.legend__swatch--gap { background: var(--clay-500); }
.legend__swatch--near { background: var(--amber-500); }

.divider {
  block-size: 1px;
  background: var(--line-soft);
  margin-block: 1rem;
  border: 0;
}

.desc {
  white-space: pre-wrap;
  line-height: 1.7;
  color: var(--ink-700);
}

.filterbar {
  display: flex;
  gap: .6rem;
  align-items: flex-end;
  flex-wrap: wrap;
  padding: .9rem 1.1rem;
  background: var(--surface-sunk);
  border-block-end: 1px solid var(--line);
}

.filterbar .field { margin-block-end: 0; }
.filterbar .input, .filterbar .select { min-block-size: 38px; padding-block: .35rem; }

/* Pagination. No mirroring by hand: `justify-content: space-between` in an RTL
   document already puts "previous" on the reader's starting side. */
.pager {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .8rem;
  margin-block-start: 1rem;
  padding-block-start: .9rem;
  border-block-start: 1px solid var(--line);
}

.pager__range {
  font-size: .86rem;
  color: var(--ink-700);
  font-variant-numeric: tabular-nums;
}

.pager .btn[disabled] { opacity: .45; cursor: default; }

.auth__aside {
  text-align: center;
  font-size: .88rem;
  margin-block: .9rem 0;
}
.auth__aside a { color: var(--teal-700); }

/* ==========================================================================
   Account
   ========================================================================== */
.usermenu__me {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  color: inherit;
  text-decoration: none;
  border-radius: var(--r-pill);
  padding-inline-end: .4rem;
}
.usermenu__me:hover { background: var(--surface-sunk); }

.kv {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding-block: .4rem;
  border-block-end: 1px solid var(--line-soft);
}
.kv:last-child { border-block-end: 0; }

/* Clay, the colour that means "gap" everywhere else — here it means "this
   removes things". Never used decoratively. */
.card--danger { border-color: var(--clay-100); background: var(--clay-050); }
.card--danger .card__title { color: var(--clay-700); }

/* ==========================================================================
   Requirement editor

   One row per *unit*, not per skill: "FastAPI or Django" is a single thing to
   satisfy, so it is a single thing to edit.
   ========================================================================== */
.reqrow {
  display: flex;
  align-items: center;
  gap: .75rem;
  flex-wrap: wrap;
  padding: .6rem .75rem;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface);
}

.reqrow__name {
  flex: 1 1 14rem;
  display: flex;
  align-items: center;
  gap: .3rem;
  flex-wrap: wrap;
  font-weight: 500;
}

.reqrow__skill { display: inline-flex; align-items: center; gap: .1rem; }

/* The per-option remove is a bare glyph, so keep its hit area honest.
   It is also deliberately NOT the same weight as the card's own remove. An
   alternative pair used to carry three identical × glyphs meaning two
   different things — drop this option, or drop the whole requirement — with
   nothing but position to tell them apart. This one is lighter and sits tight
   against the word it belongs to; the card's is heavier and lives in the
   corner. The aria-labels already named the difference; now the eye can see
   it too. */
.reqrow__skill .btn {
  min-inline-size: 1.4rem;
  padding-inline: .15rem;
  font-size: .82rem;
  color: var(--ink-300);
}
.reqrow__skill .btn:hover { color: var(--clay-700); background: transparent; }

.reqrow .select { min-inline-size: 9rem; }
.reqrow .input, .reqrow .select { min-block-size: 36px; padding-block: .3rem; }

/* --- The two tiers, as two sections --------------------------------------
   Measured before it was changed: thirteen full-width rows at 107px each ran
   1250px down the page to say thirteen words, with about 650px of white in
   every row between the skill name and its controls.
   The tier was the only structural fact on the screen and it was not in the
   structure — it was a <select> repeated thirteen times spelling out the same
   two strings. Sections state it once, which deletes all thirteen controls and
   turns the remaining one into a verb. */
.reqtiers { display: grid; gap: 1.6rem; }

.reqtier__head {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr);
  align-items: baseline;
  gap: .55rem;
  margin-block-end: .8rem;
}
.reqtier__title { margin: 0; font-size: 1rem; }
.reqtier__count {
  font-variant-numeric: tabular-nums;
  font-size: .8rem;
  font-weight: 600;
  padding: .05rem .45rem;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--tier) 16%, var(--surface));
  color: var(--tier-ink);
}
/* The hint is the whole reason a section beats a dropdown: there is room to
   say what the tier MEANS, once, instead of naming it thirteen times. */
.reqtier__hint {
  margin: 0;
  color: var(--ink-500);
  font-size: .85rem;
  min-inline-size: 0;
}

/* Teal is "held" and clay is "gap" everywhere in this product, and neither
   means "required". The tiers borrow the ink neutrals plus a single accent
   from the family that is already the page's own: --tier tints the count chip
   and the rail, --tier-ink carries the count's digits and is a text token that
   has been measured. */
.reqtier--must { --tier: var(--teal-500); --tier-ink: var(--teal-700); }
.reqtier--nice { --tier: var(--ink-300); --tier-ink: var(--ink-700); }

.reqgrid {
  display: grid;
  /* Three across at this page's width, one on a phone. `1fr` as the maximum
     rather than a length: auto-fit takes its repetition count from the max
     when that is definite, which is how a two-column rule elsewhere in this
     file silently rendered one column. */
  grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr));
  gap: .7rem;
}

.reqcard {
  display: grid;
  /* The name row takes what it needs and the foot is pushed to the bottom, so
     a card whose skill wraps to two lines ("Natural Language Processing", or
     an alternative pair) does not leave the cards beside it with their
     controls floating in the middle of a stretched box. Every foot in a row
     lands on the same line. */
  grid-template-rows: auto 1fr;
  gap: .55rem;
  padding: .7rem .8rem;
  border: 1px solid var(--line);
  /* The rail says which tier without a word, and matches the count chip. */
  border-inline-start: 3px solid color-mix(in srgb, var(--tier) 70%, var(--line));
  border-radius: var(--r-md);
  background: var(--surface);
}

/* A unit weighted above the default is the one thing on this screen the
   employer chose by hand, and it used to look identical to the twelve
   defaults around it. */
.reqcard[data-weighted="true"] {
  border-color: color-mix(in srgb, var(--tier) 40%, var(--line));
  background: color-mix(in srgb, var(--tier) 5%, var(--surface));
}
.reqcard[data-weighted="true"] .reqcard__wlabel { color: var(--tier-ink); font-weight: 600; }

.reqcard__top {
  display: flex;
  align-items: flex-start;
  gap: .4rem;
}
.reqcard__name {
  flex: 1 1 auto;
  min-inline-size: 0;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: .25rem;
  font-weight: 500;
  line-height: 1.35;
}
.reqcard__or { color: var(--ink-500); font-size: .82rem; }

/* Remove was a red button on every one of thirteen rows, which made the
   destructive action the loudest thing on the page. A glyph that darkens to
   clay on hover keeps the meaning and stops the shouting. */
.reqcard__drop {
  flex: 0 0 auto;
  min-inline-size: 1.8rem;
  padding-inline: .3rem;
  color: var(--ink-500);
  font-size: 1.05rem;
  line-height: 1;
}
.reqcard__drop:hover { color: var(--clay-700); background: var(--clay-050); }

.reqcard__foot {
  align-self: end;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .5rem;
  flex-wrap: wrap;
}
/* The weight used to be a naked `1` in a box under an unlabelled dropdown.
   Its name is on the screen now rather than only in an aria-label. */
.reqcard__weightbox {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  font-size: .8rem;
  color: var(--ink-500);
}
.reqcard__weight {
  inline-size: 4.2rem;
  min-block-size: 32px;
  padding-block: .2rem;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
/* This is the control that replaced thirteen dropdowns, so it has to read as
   a control. The first version was ink-500 text with no affordance at all and
   looked like a caption — quiet was the right instinct, invisible was not. A
   hairline pill is enough to say "pressable" without competing with the
   skill's own name. */
.reqcard__move {
  font-size: .76rem;
  color: var(--ink-500);
  white-space: nowrap;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  padding: .18rem .6rem;
  background: var(--surface);
}
.reqcard__move:hover {
  color: var(--tier-ink);
  border-color: color-mix(in srgb, var(--tier) 45%, var(--line));
  background: color-mix(in srgb, var(--tier) 8%, var(--surface));
}

.reqtier__empty {
  margin: 0;
  padding: .7rem .85rem;
  border-radius: var(--r-md);
  font-size: .88rem;
}
/* The single place clay is spent on this screen. An advert with no must-haves
   ranks every applicant much the same, which is the one state here that is
   worth a warning. */
.reqtier__empty--warn {
  background: var(--clay-050);
  border: 1px solid var(--clay-100);
  color: var(--clay-700);
}
.reqtier__empty--quiet {
  background: var(--surface-sunk);
  border: 1px solid var(--line-soft);
  color: var(--ink-500);
}

/* ==========================================================================
   Switch

   A consent control, so it is deliberately not a decorative slider: the track
   turns teal only when the answer is actually "yes", using the same teal that
   means "matched" everywhere else.
   ========================================================================== */
.switch {
  display: flex;
  align-items: flex-start;
  gap: .7rem;
  cursor: pointer;
  min-block-size: var(--tap);
  padding-block: .2rem;
}

.switch__input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.switch__track {
  position: relative;
  flex: none;
  inline-size: 44px;
  block-size: 26px;
  margin-block-start: .1rem;
  border-radius: var(--r-pill);
  background: var(--line);
  transition: background .18s ease;
}

.switch__thumb {
  position: absolute;
  inset-block-start: 3px;
  inset-inline-start: 3px;
  inline-size: 20px;
  block-size: 20px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--shadow-sm);
  /* A translation, not an inset change, so the thumb slides the correct way
     in both directions without a second rule for rtl. */
  transition: transform .18s ease;
}

.switch__input:checked + .switch__track { background: var(--teal-500); }
.switch__input:checked + .switch__track .switch__thumb {
  transform: translateX(18px);
}
html[dir="rtl"] .switch__input:checked + .switch__track .switch__thumb {
  transform: translateX(-18px);
}

.switch__input:focus-visible + .switch__track {
  outline: 2px solid var(--teal-500);
  outline-offset: 2px;
}

.switch__input:disabled + .switch__track { opacity: .55; }

.switch__text { display: flex; flex-direction: column; gap: .15rem; }
.switch__label { font-weight: 500; }

@media (prefers-reduced-motion: reduce) {
  .switch__track, .switch__thumb { transition: none; }
}

@media (max-width: 640px) {
  .topbar__inner { flex-wrap: wrap; }
  .topnav { order: 3; inline-size: 100%; }
  h1 { font-size: 1.45rem; }
  .main { padding-inline: .9rem; }
}

/* ==========================================================================
   The professional lens

   The page rearranges itself around the field it read off the CV — an
   engineer's skills as a stack, a nurse's as a scope of practice. See
   js/lens.js for the fold and the measurement behind it.

   ONE RULE GOVERNS THE COLOUR HERE. Teal and clay mean "held" and "missing"
   everywhere in this file, so a family is not allowed to own either, and is not
   allowed to tint anything a reader could mistake for a score: no chip, no
   gauge, no skill row. The family tint appears on the identity band and nothing
   else, at a saturation low enough to read as paper rather than as signal. The
   family is named in words a few pixels away; the colour is decoration, and the
   words are the information.

   The families differ far more by TEXTURE than by hue — the mono rail of a
   stack, the wide air of a portfolio — because that is the part that survives
   being printed in greyscale or read by someone who cannot separate the tints.
   ========================================================================== */

.lens {
  --lens-tint: #f2f1ee;
  --lens-ink: #55605c;
  --lens-edge: #9aa5a0;

  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
  padding: 1rem 1.15rem;
  margin-block-end: 1rem;
  background: var(--lens-tint);
  border: 1px solid var(--line-soft);
  border-inline-start: 3px solid var(--lens-edge);
  border-radius: var(--r-lg);
}

/* INSIDE A CARD IT STOPS BEING A BOX.
 *
 * Border, fill, radius and shadow each say "separate object", and the fingerprint
 * screen was spending all four twice over: a bordered, rounded, shadowed card
 * holding a bordered, rounded, tinted band with a rail of its own, holding a row
 * of chips. Three levels of container for one statement. When everything is a
 * card, nothing is — and the thing that actually deserved lifting, the reading
 * itself, was the middle layer of a nest.
 *
 * So the band keeps the two devices that carry meaning and loses the two that
 * only carry separation. The tint stays, because it IS the family's identity and
 * lens-palette.test.js measures every family name against it. The rail stays,
 * because it is that identity at full strength. The border and the radius go,
 * and the band reaches the card's own edges — a header band belonging to the
 * card, the way a table's head belongs to the table, rather than a second
 * object floating inside the first.
 *
 * Standalone — the requirements editor, a job page — it keeps the box. There it
 * has no card around it, so the box is what separates it from the page.
 */
.card .lens {
  margin-inline: calc(var(--card-pad, 1.35rem) * -1);
  border-block: 0;
  border-inline-end: 0;
  border-radius: 0;
}

/* Three stops of one hue per family, generated in CIE Lab rather than picked by
   eye, and every number below is checked by tests/lens-palette.test.js.
   ---------------------------------------------------------------------------
   WHAT THE FIRST ATTEMPT GOT WRONG. Eight washes chosen by eye measured ΔE 1.48
   between the closest two — BELOW the 2.3 just-noticeable difference, meaning
   two families were literally the same colour to a reader. Readability was
   never the problem: that palette already ran 5.9:1. Separation was.

   TWO ARCS ARE FORBIDDEN, by measurement and not by taste. Hues near 40-70 land
   on the clay wash that means "gap"; hues near 175-205 land on the teal wash
   that means "held". A family tint that close is a colour making a claim about
   a score. The eight hues are spread over what remains, and the tint lightness
   alternates a little between neighbours — hue alone cannot separate eight
   colours that must all stay quiet, and two points of lightness buy separation
   without buying saturation.

   WHERE IT SETTLED, and the trade is real: eight simultaneously distinguishable
   hues cannot all stay whisper-quiet. Closest pair ΔE 4.93 — twice the JND, and
   as far as this can go before a band starts reading as a signal.

     --lens-edge is the only strong stop, and it touches no text: the band's
     leading rule and the ring around the mark, both non-text, both above the
     3:1 that non-text contrast asks for. Names keep --lens-ink at 5.9:1. */
.lens[data-family="industry"]   { --lens-tint: #ffe7ea; --lens-ink: #7a4d53; --lens-edge: #b6707a; }
.lens[data-family="legal"]      { --lens-tint: #fff5e3; --lens-ink: #695739; --lens-edge: #9b8050; }
.lens[data-family="business"]   { --lens-tint: #eaeedb; --lens-ink: #555d3c; --lens-edge: #7c8955; }
.lens[data-family="education"]  { --lens-tint: #e8fbed; --lens-ink: #3f614a; --lens-edge: #598f6a; }
.lens[data-family="operations"] { --lens-tint: #d5f2f7; --lens-ink: #23626b; --lens-edge: #1d91a1; }
.lens[data-family="tech"]       { --lens-tint: #e7f9ff; --lens-ink: #385d78; --lens-edge: #498ab5; }
.lens[data-family="health"]     { --lens-tint: #ebebfe; --lens-ink: #565777; --lens-edge: #7d7fb4; }
.lens[data-family="design"]     { --lens-tint: #fff2ff; --lens-ink: #6b516c; --lens-edge: #a075a2; }

/* The mark is a text glyph, never an image: it inherits colour, scales with the
   type, costs no request and cannot 404. The explicit stack is what makes that
   safe — the Arabic text face carries no geometric symbols, so without it each
   mark would land in whatever fallback the platform picked and the eight would
   not look like one set. `text` presentation stops a platform promoting any of
   them to a colour emoji. */
.lens__mark {
  display: grid;
  place-items: center;
  inline-size: 46px;
  block-size: 46px;
  flex: none;
  border-radius: var(--r-md);
  background: var(--surface);
  color: var(--lens-ink);
  font-family: "Segoe UI Symbol", "Apple Symbols", "Noto Sans Symbols 2", var(--font-body);
  font-variant-emoji: text;
  font-size: 1.35rem;
  line-height: 1;
  box-shadow: var(--shadow-sm), inset 0 0 0 1px var(--lens-edge);
  transition: transform .35s cubic-bezier(.2, .8, .3, 1);
}
.lens:hover .lens__mark { transform: translateY(-2px) rotate(-4deg); }

.lens__body { flex: 1 1 14rem; min-inline-size: 0; }

.lens__eyebrow {
  font-size: .75rem;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.lens__name {
  font-family: var(--font-display);
  font-size: 1.2rem;
  font-weight: 600;
  color: var(--lens-ink);
}

.lens__hint { font-size: .85rem; color: var(--ink-500); margin: 0; }

/* The switcher. A detected field is a reading, not a verdict, so the reader can
   overrule it — and the overrule is the whole point of the component swap. */
.lens__switch {
  display: flex;
  gap: .35rem;
  flex-wrap: wrap;
  flex: 1 1 100%;
  padding-block-start: .75rem;
  border-block-start: 1px dashed var(--line);
}

.lens__option {
  appearance: none;
  border: 1px solid transparent;
  background: rgba(255, 255, 255, .6);
  color: var(--ink-700);
  font: inherit;
  font-size: .82rem;
  padding: .3rem .7rem;
  min-block-size: 32px;
  border-radius: var(--r-pill);
  cursor: pointer;
  transition: background .18s ease, color .18s ease, transform .18s ease, border-color .18s ease;
}
.lens__option:hover { background: var(--surface); transform: translateY(-1px); }
.lens__option[aria-pressed="true"] {
  background: var(--surface);
  border-color: var(--lens-ink);
  color: var(--lens-ink);
  font-weight: 600;
}

/* --- the grouped skills ------------------------------------------------- */

.lensgroups { display: grid; gap: 1.1rem; }

.lensgroup {
  animation: lens-rise .34s cubic-bezier(.2, .8, .3, 1) both;
  animation-delay: calc(var(--i, 0) * 45ms);
}

@keyframes lens-rise {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}

.lensgroup__head {
  display: flex;
  align-items: baseline;
  gap: .5rem;
  margin-block-end: .5rem;
}

.lensgroup__title {
  font-family: var(--font-display);
  font-size: .95rem;
  font-weight: 600;
}

.lensgroup__count {
  font-family: var(--font-mono);
  font-size: .75rem;
  color: var(--ink-500);
}

/* Texture: the stack. Groups read as layers, so each one carries a rail and a
   machine-set label — the visual grammar an engineer already reads all day. */
.lens--mono .lensgroup {
  padding-inline-start: .85rem;
  border-inline-start: 2px solid var(--line);
}
.lens--mono .lensgroup:hover { border-inline-start-color: var(--ink-300); }
.lens--mono .lensgroup__title {
  font-family: var(--font-mono);
  font-size: .78rem;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-700);
}

/* Texture: the record. A scope of practice is not a list — it is a set of named
   bands, each holding what falls inside it, which is how a clinical record, a
   rota and a medication chart are all set. So the group name holds a fixed
   column on the reader's starting side and its skills flow beside it, ruled off
   by a hairline. The eye can run down the names alone and find the band it
   wants, which is the thing a list cannot do.

   Chosen for health first because health earned it by measurement: the highest
   dominant share of any family on real CVs (91% at worst), and two real CVs to
   check it against. */
.lens--chart .lensgroups { gap: 0; }

.lens--chart .lensgroup {
  display: grid;
  grid-template-columns: minmax(6.5rem, 8.5rem) minmax(0, 1fr);
  gap: 0 1.1rem;
  align-items: start;
  padding-block: .8rem;
  border-block-start: 1px solid var(--line-soft);
}
.lens--chart .lensgroup:first-child { border-block-start: 0; padding-block-start: 0; }

.lens--chart .lensgroup__head {
  display: flex;
  flex-direction: column;
  gap: .1rem;
  margin-block-end: 0;
}
.lens--chart .lensgroup__title { font-size: .88rem; color: var(--ink-700); }
.lens--chart .lensgroup__count { font-size: .7rem; }

/* Texture: the board. Trades are organised by station — a discipline is a
   bounded thing you either cover or do not — so this is the one texture that
   encloses its groups, laid out as tiles the way a workshop's manning board is.

   THE TALLY IS THE POINT, and it comes from the two real CVs this was designed
   against. They are shaped differently: one covers three trades at once
   (plumbing, tiling, electrical, HVAC, welding), the other holds seven skills
   inside a single production system (ISO, Lean, Six Sigma, production
   planning). Breadth and depth are both answers to "what can you cover", so the
   count moves out of the faint mono footnote it is elsewhere and onto the
   panel's header, where a foreman reads it first.

   Flat on purpose — no shadow, no hover lift. These are panels, not cards, and
   nothing here is clickable. */
.lens--board .lensgroups {
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  gap: .75rem;
}

.lens--board .lensgroup {
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  overflow: hidden;
}

.lens--board .lensgroup__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: .5rem;
  margin-block-end: 0;
  padding: .45rem .7rem;
  background: var(--surface-sunk);
  border-block-end: 1px solid var(--line);
}

.lens--board .lensgroup__title { font-size: .85rem; }

.lens--board .lensgroup__count {
  font-size: .82rem;
  font-weight: 600;
  color: var(--ink-700);
}

.lens--board .lensgroup > .stack { padding: .6rem .7rem; }

/* Texture: air. Whitespace is the layout, groups flow into columns when the
   screen allows, and nothing is boxed. */
.lens--airy .lensgroups { gap: 2rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); }
.lens--airy .lensgroup__title { font-size: 1.1rem; }
.lens--airy .chips { gap: .5rem; }

@media (max-width: 640px) {
  .lens { padding: .85rem; }
  .lens--airy .lensgroups { grid-template-columns: minmax(0, 1fr); gap: 1.25rem; }
  .lens__mark { inline-size: 38px; block-size: 38px; font-size: 1.1rem; }

  /* The record needs a label column wide enough to hold a band name. On a phone
     there is no such width, so it stacks rather than hyphenating "الممارسة
     السريرية" down a 5rem gutter. */
  .lens--chart .lensgroup { grid-template-columns: minmax(0, 1fr); gap: .35rem; }
  .lens--chart .lensgroup__head { flex-direction: row; align-items: baseline; gap: .5rem; }
}

@media (prefers-reduced-motion: reduce) {
  .lensgroup { animation: none; }
  .lens__mark, .lens__option { transition: none; }
  .lens:hover .lens__mark { transform: none; }
}

/* ==========================================================================
   The fingerprint hero
   ==========================================================================
   A figure generated from the reader's own extraction — see
   js/views/fingerprint.js for why it is a portrait rather than an ornament.

   Everything here sits on a dark ground, which is doing real work and not just
   looking modern. The expressive ramp is saturated; saturated colour on white
   is garish and on near-black is luminous, and luminous is what makes a page
   feel like a product rather than a form. It also means the one piece of text
   in this block is white on #062825 — 15.8:1 — so the loudest part of the
   design is also the most legible part of it.
   ========================================================================== */
.fp {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 1.75rem;
  align-items: center;
  padding: 1.75rem 1.9rem;
  margin-block-end: 1.1rem;
  border-radius: var(--r-lg);
  /* The same ink as the sign-in screen rather than the old teal-black. Two
     dark grounds a user meets minutes apart read as two products, and this one
     is also the more legible: white on it is 18.10:1 against 15.8:1. */
  background: linear-gradient(160deg, #070d0c, #0c1a18);
  color: #fff;
  overflow: hidden;
  isolation: isolate;
}

/* The moving field. Behind the glow and the content, drawn by canvasfield.js,
   and never interactive — it is the grain of the material. */
.fp__field {
  position: absolute;
  inset: 0;
  z-index: -2;
  display: block;
  inline-size: 100%;
  block-size: 100%;
  pointer-events: none;
}

/* The bloom behind the figure. Pointer-events off and aria-hidden in the markup:
   it is atmosphere, and must never intercept a click or be announced. */
.fp__glow {
  position: absolute;
  inset: -30%;
  background: var(--hero-glow);
  filter: blur(6px);
  pointer-events: none;
  z-index: -1;
}

.fp__figure { position: relative; inline-size: 240px; block-size: 240px; }
.fp__svg { inline-size: 100%; block-size: 100%; display: block; overflow: visible; }

.fp__ring {
  fill: none;
  stroke: rgba(255, 255, 255, .10);
  stroke-width: 1;
}

.fp__spoke {
  stroke-width: 3;
  stroke-linecap: round;
  opacity: .92;
}

.fp__dot { opacity: .95; }

/* The count, sitting in the eye of the figure. */
.fp__centre {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
}
.fp__count {
  font-family: var(--font-display);
  font-size: 2.4rem;
  font-weight: 600;
  letter-spacing: -.03em;
  color: #fff;
}

.fp__eyebrow {
  margin: 0 0 .3rem;
  font-size: .82rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  /* Not --ink-500: this is white text on a dark ground, and the neutral ramp is
     defined for the light surfaces. 68% white on #062825 is 8.9:1. */
  color: rgba(255, 255, 255, .68);
}

.fp__headline {
  font-size: 2.1rem;
  line-height: 1.1;
  letter-spacing: -.03em;
  margin: 0 0 1.1rem;
  color: #fff;
}

/* Capped, because the count belongs beside its label rather than at the far
   edge of a 1280px card. Without this the flexible name cell stretches the full
   width of the hero and the two numbers that matter end up a hand's width from
   the words they belong to. */
.fp__stats {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* The column gap is deliberately far larger than the row gap. Each row puts
     its count hard against the inline-end of its own cell, so at a small
     column gap the first column's number sits beside the second column's
     swatch and the eye reads "2 ▪ Services & data 8" as one row. The gap is
     what makes a pair a pair. */
  gap: .45rem 3rem;
  /* Two columns once the panel is wide enough for them, capped at 24rem each
     so a family name never stretches into a line you have to track across.
     A single 24rem column left roughly a third of the employer's hero empty
     to the inline-end of it — six short rows down the near edge of a very wide
     dark panel. The lens emits at most six groups, so this is three rows.
     `1fr` AS THE MAXIMUM, and this took two measurements to get right.
     auto-fit picks its repetition count from the track's MAX sizing function
     whenever that is definite. With `minmax(15rem, 24rem)` the browser was
     therefore asking whether two 24rem tracks plus this 3rem gap fit: 816px
     needed against a panel measured at 798px, so it settled on one column and
     the rule looked like it had done nothing. `min(100%, 15rem)` as the
     minimum failed the same way for a different reason — a percentage there
     makes the count indefinite, and the fallback is again a single track.
     With `1fr` the maximum is indefinite, so the count comes from the 15rem
     minimum: two tracks need 528px and get 375px each. It is also the
     answer that is not tuned to one viewport.
     The cap keeps a count from drifting a hand's width from its own label on a
     very wide hero: two 24rem columns and the gap between them. */
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  max-inline-size: 51rem;
}

.fp__stat {
  display: flex;
  align-items: center;
  gap: .6rem;
  font-size: .92rem;
  color: rgba(255, 255, 255, .88);
}
.fp__swatch {
  inline-size: 10px;
  block-size: 10px;
  border-radius: 3px;
  flex: 0 0 auto;
}
.fp__stat-name { flex: 1 1 auto; min-inline-size: 0; }
.fp__stat-count {
  font-variant-numeric: tabular-nums;
  color: #fff;
  font-weight: 600;
}

/* --- Motion ---------------------------------------------------------------
   Gated behind `no-preference`, which is the safe direction: a browser that
   does not answer the query gets the static figure rather than an animation
   nobody asked for. It is also load-bearing for the test suite — a11y.spec.js
   runs every scan with reduced motion, and a figure that animated regardless
   would put axe back to measuring half-drawn pixels. That cost a day once.

   The stagger is one custom property per element (--i) rather than a keyframe
   each, so 26 spokes cost 26 numbers and no extra rules. */
@media (prefers-reduced-motion: no-preference) {
  .fp__spoke,
  .fp__dot {
    transform-origin: 120px 120px;
    animation: fp-sweep .5s cubic-bezier(.22, 1, .36, 1) backwards;
    animation-delay: calc(var(--i, 0) * 22ms);
  }
  .fp__stat {
    animation: fp-rise .45s cubic-bezier(.22, 1, .36, 1) backwards;
    animation-delay: calc(240ms + var(--i, 0) * 45ms);
  }
  .fp__glow { animation: fp-drift 18s ease-in-out infinite alternate; }
}

@keyframes fp-sweep {
  from { transform: scale(.35) rotate(-14deg); opacity: 0; }
  to   { transform: none; opacity: inherit; }
}
@keyframes fp-rise {
  from { transform: translateY(6px); opacity: 0; }
  to   { transform: none; opacity: 1; }
}
/* Slow, and alternating rather than looping, so nothing ever jumps back to a
   start position in the corner of somebody's eye while they read. */
@keyframes fp-drift {
  from { transform: translate3d(-2%, -1%, 0) scale(1); }
  to   { transform: translate3d(2%, 2%, 0) scale(1.06); }
}

/* --- The phone ------------------------------------------------------------
   Stacked, and the figure shrinks. 240px beside text in a 360px column is not
   a layout, and mobile.spec.js measures overflow rather than crowding — so
   this is the kind of thing only a screenshot catches. */
@media (max-width: 640px) {
  .fp {
    grid-template-columns: minmax(0, 1fr);
    justify-items: center;
    text-align: center;
    padding: 1.4rem 1.1rem;
  }
  .fp__figure { inline-size: 190px; block-size: 190px; }
  .fp__headline { font-size: 1.6rem; }
  .fp__stats { inline-size: 100%; }
  .fp__stat { text-align: start; }
}

/* ==========================================================================
   The page ground, and the ramp as connective tissue
   ==========================================================================
   The hero had an identity and everything below it was a white form. This is
   the other half: the same six colours that draw the fingerprint's spokes
   reappear as each group's rail and swatch, so the figure at the top of the
   page becomes a legend for the list underneath rather than a separate
   picture. The reader is never told the mapping; they see it.

   WHAT IS AND IS NOT COLOURED. Only rails, swatches, dots and washes — never a
   letter. Every group title stays --ink-900 and every count stays --ink-500,
   because those are text and text is pinned by contrast measurement. A vivid
   heading on white is exactly the trade a redesign makes without noticing, and
   it fails AA at every one of these hues.
   ========================================================================== */

/* A living ground instead of flat paper.
 *
 * Fixed and behind everything, at an opacity low enough that the page still
 * reads as light paper rather than as a coloured surface — the washes are
 * 5-7% and enormous, so no edge of one ever lands under a paragraph as a
 * gradient a reader can perceive. axe checks the composited result against
 * every text colour on ten routes, which is what makes this safe to do at all
 * rather than a guess about how light is light enough. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(46% 38% at 12% 4%, rgba(46, 230, 168, .07), transparent 70%),
    radial-gradient(42% 34% at 88% 0%, rgba(107, 138, 253, .07), transparent 70%),
    radial-gradient(50% 40% at 96% 62%, rgba(169, 123, 245, .05), transparent 70%);
}

/* Each group gets its colour as a rail and a dot. */
.lensgroup__head {
  border-block-end: 1px solid var(--line-soft);
  padding-block-end: .4rem;
}

.lensgroup__title {
  display: flex;
  align-items: center;
  gap: .5rem;
}
.lensgroup__title::before {
  content: "";
  inline-size: 9px;
  block-size: 9px;
  border-radius: 3px;
  background: var(--g, var(--teal-500));
  flex: 0 0 auto;
}

/* The rail. Two pixels of the group's colour down the side of its rows, which
   is what stops a long page from reading as one undifferentiated column. */
.lensgroup .stack {
  border-inline-start: 2px solid var(--g, var(--line-soft));
  padding-inline-start: .85rem;
  margin-inline-start: 3px;
}

/* --- Rows that respond ----------------------------------------------------
   A row is a real unit of content — a skill and the sentence it was read from
   — and until now nothing acknowledged the pointer at all. The tint is drawn
   from the group's own colour so the feedback belongs to where the cursor is
   rather than being a generic grey. */
.skillrow {
  padding-inline: .55rem;
  margin-inline: -.55rem;
  border-radius: var(--r-sm);
  transition: background-color .16s ease;
}
.skillrow:hover {
  background: color-mix(in srgb, var(--g, var(--teal-500)) 9%, transparent);
}
.skillrow:hover .meter__fill { filter: saturate(1.3); }

/* The meter takes the group's colour too, so a column of bars reads as the
   same six families the figure above is drawn from. --teal-500 stays the
   fallback for the places a meter appears outside a group. */
.lensgroup .meter__fill { background: var(--g, var(--teal-500)); }
/* Except the semantic one. An amber fill means "this came from the semantic
   layer rather than the dictionary", which is a claim about provenance, and it
   must not be repainted into a decorative family colour. */
.lensgroup .meter__fill--semantic { background: var(--amber-500); }

/* --- Cards with an edge ---------------------------------------------------
   A hairline of the identity along the top of the main card. Enough to say the
   page and the hero are the same product; not enough to compete with them. */
.card { position: relative; }

.grid--sidebar > .card:first-child::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline: 0;
  block-size: 3px;
  border-start-start-radius: var(--r-lg);
  border-start-end-radius: var(--r-lg);
  background: linear-gradient(90deg, var(--fp-1), var(--fp-2), var(--fp-3), var(--fp-4), var(--fp-5), var(--fp-6));
  opacity: .9;
}

@media (prefers-reduced-motion: reduce) {
  .skillrow { transition: none; }
}

/* ==========================================================================
   Presence
   ==========================================================================
   The product was austere: white cards, thin rules, one accent, no icons. It
   read as a form rather than as a product. This gives the pieces a person
   actually touches — tiles, buttons, cards, headers — some weight and some
   colour, using the identity ramp already established rather than a new one.

   The discipline stays the same as everywhere else: colour goes into fills,
   icons, edges and washes, never into a letter that has to be read. Every
   label in here keeps its measured ink colour.
   ========================================================================== */

.icon { flex: 0 0 auto; }

/* --- Stat tiles -----------------------------------------------------------
   Four identical white boxes said nothing about which number mattered. Each
   tile now carries its own hue from the ramp, a tinted icon chip, and a wash
   that lifts it off the page. */
.stats {
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: .85rem;
}

/* Laid out sideways: the chip leads, the number and its name sit beside it.
   Stacked, the tile was mostly empty — a small icon, a short number and a
   one-word label leave a hand's width of nothing to their side, and four of
   those in a row read as four blank boxes with something in the corner. */
.stat {
  --tone: var(--teal-500);
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: .85rem;
  background:
    linear-gradient(150deg,
      color-mix(in srgb, var(--tone) 20%, var(--surface)),
      color-mix(in srgb, var(--tone) 6%, var(--surface)) 70%);
  border: 1px solid color-mix(in srgb, var(--tone) 34%, var(--line-soft));
  border-radius: var(--r-md);
  padding: .95rem 1.05rem;
  overflow: hidden;
}
.stat--1 { --tone: var(--fp-1); }
.stat--2 { --tone: var(--fp-2); }
.stat--3 { --tone: var(--fp-3); }
.stat--4 { --tone: var(--fp-4); }
.stat--5 { --tone: var(--fp-5); }
.stat--6 { --tone: var(--fp-6); }

/* A tile whose number is a score takes the score's own colour.
   The wash is 12% rather than the 20% above, and that is measured: the number
   is drawn IN --tone here, so it has to clear 4.5:1 against its own tile. At
   20% the mid band lands 4.20 and the weak band 4.33 — both under AA. At 12%
   the worst of the three is 4.72 (mid), which keeps a real margin rather than
   sitting on the threshold waiting for a rounding difference.
   The number's colour comes from SCORE_COLORS in js/ui.js and these tokens are
   the same ones; lens-palette.test.js reads both and fails if they part. */
.stat--score {
  --tone: var(--band);
  background:
    linear-gradient(150deg,
      color-mix(in srgb, var(--band) 12%, var(--surface)),
      color-mix(in srgb, var(--band) 4%, var(--surface)) 70%);
  border-color: color-mix(in srgb, var(--band) 30%, var(--line-soft));
}
.stat--score .stat__value { color: var(--band); }
.stat--score[data-band="strong"] { --band: var(--teal-700); }
.stat--score[data-band="mid"]    { --band: var(--amber-700); }
.stat--score[data-band="weak"]   { --band: var(--clay-700); }

.stat__icon {
  /* Spans both rows, so the number and its name stack beside the chip. Without
     this the three children fill the grid in order and the label wraps to the
     second row of the FIRST column — under the icon, beside nothing. */
  grid-row: 1 / span 2;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  inline-size: 40px;
  block-size: 40px;
  border-radius: 12px;
  /* Toward ink, not toward white, and this was a real failure rather than a
     preference.
     The rule here used to read `color-mix(var(--tone) 88%, var(--surface))`
     with a white glyph, justified by "the icon is a shape rather than text, so
     it may sit on the saturated hue directly". The threshold in that sentence
     is right — a glyph is non-text and needs 3.0:1, not 4.5:1 — but nothing
     had ever measured against it. Measured, all six chips failed:
       --fp-1 1.55   --fp-2 1.66   --fp-3 2.70
       --fp-4 2.64   --fp-5 2.24   --fp-6 1.49
     The ramp is bright by design; white on bright is not a contrast.
     axe never caught it because its colour-contrast rule tests text only, so
     this sat in the product behind a green suite.
     Mixing the hue toward --ink-900 keeps the chip unmistakably its family's
     colour while giving the glyph a ground to read on. At 60% the worst of the
     six is 3.57:1 (--fp-6); at 70% it falls back under to 2.85. */
  background: color-mix(in srgb, var(--tone) 60%, var(--ink-900));
  color: #fff;
  box-shadow: 0 2px 8px -2px color-mix(in srgb, var(--tone) 60%, transparent);
}

/* --- Buttons that look pressable ------------------------------------------
   A flat fill is fine and says nothing. The gradient and the lift are what
   make the primary action look like the primary action from across the page. */
.btn--primary {
  background: linear-gradient(160deg, var(--teal-700), var(--teal-900));
  border-color: transparent;
  box-shadow: 0 1px 2px rgba(6, 65, 59, .25), 0 6px 16px -6px rgba(6, 65, 59, .45);
}
.btn--primary:hover:not(:disabled) {
  background: linear-gradient(160deg, var(--teal-500), var(--teal-700));
  transform: translateY(-1px);
  box-shadow: 0 2px 4px rgba(6, 65, 59, .25), 0 10px 22px -8px rgba(6, 65, 59, .5);
}
.btn--primary:active:not(:disabled) { transform: translateY(0); }
.btn { transition: transform .14s ease, box-shadow .14s ease, background-color .14s ease; }

.btn--secondary:hover:not(:disabled) {
  border-color: var(--teal-700);
  background: var(--teal-050);
}

/* --- Cards with a top light ----------------------------------------------
   A single soft wash at the head of a card, so a column of them reads as a
   stack of objects rather than as ruled paper. */
.card--sunk {
  background: linear-gradient(170deg, var(--teal-050), var(--surface-sunk) 70%);
  border: 1px dashed var(--line-2, var(--line));
}

/* --- Job cards ------------------------------------------------------------
 *
 * Five screens are one component: the match feed, all jobs, my applications,
 * the employer overview and my postings. Everything below is written once and
 * lands on all five.
 *
 * THE PROBLEM IT SOLVES. A screenshot of the feed showed thirteen identical
 * white bars, eight of them scoring 0%, on a page titled "Jobs that fit you".
 * The list is ranked by score and the design said nothing about score: a 100%
 * match and a 0% match were the same white bar with a different ring. So the
 * page's one organising fact was invisible until you read thirteen numbers.
 */
.jobcard {
  transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease;
}
.jobcard::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: 3px;
  background: linear-gradient(180deg, var(--fp-1), var(--fp-3));
  opacity: 0;
  transition: opacity .16s ease;
}
.jobcard { position: relative; overflow: hidden; }
.jobcard:hover::before { opacity: 1; }
.jobcard:hover { transform: translateY(-2px); }

/* --- The score band, carried by the card ---------------------------------- */
/* THE WASH GETS WEAKER AS THE MATCH GETS WEAKER, and that is the point.
 *
 * The first version washed all three bands at one strength. The screenshot of
 * the real feed showed why that is wrong: nine of thirteen jobs score under
 * 40%, so an even wash painted two thirds of a page titled "Jobs that fit you"
 * in the colour that means "gap" — the design shouting about the failures and
 * whispering the one perfect match at the top.
 *
 * A ranked list should carry its ranking in its weight. Strong is now the most
 * present card on the page and weak is nearly paper; the rail still names the
 * band at full strength, so nothing is hidden, it is only quiet. */
.jobcard[data-band="strong"] { --band: var(--teal-700); --wash: 18%; --edge: 34%; }
.jobcard[data-band="mid"]    { --band: var(--amber-700); --wash: 12%; --edge: 26%; }
.jobcard[data-band="weak"]   { --band: var(--clay-700);  --wash:  5%; --edge: 14%; }

/* A fourth value, and the only one of the four that is not a score.
 *
 * The employer's posting carries it when somebody is waiting on that posting —
 * see views/employer.js, which reads it off the unreviewed end of the pipeline
 * rather than off a total. Teal because for an employer people waiting is the
 * good news, and it is the same teal the candidate's strong band uses, so the
 * palette gains a value and not a colour.
 *
 * ONE VALUE, WHICH IS THE WHOLE REASON THIS IS ALLOWED HERE. Three strengths
 * down a list sorted by fit read as a scale, and the note below this block is
 * right that drawing that scale on a list sorted by date would claim an order
 * the list does not have. Two states cannot be a scale: a card either has
 * people on it or it does not.
 *
 * 12% and not teal's 18%. The strongest wash in the file belongs to the
 * strongest statement in the product — a 100% match — and "two people have not
 * been looked at yet" is not that. The guard in lens-palette.test.js picks this
 * rule up from the stylesheet by the same regex as the three above, so the
 * number is measured rather than chosen: it is held against its own contrast
 * ceiling and against every skill family, exactly as the others are. */
.jobcard[data-band="waiting"] { --band: var(--teal-700); --wash: 12%; --edge: 26%; }

/* 12%, AND THE CEILING IS 14%.
 *
 * I first wrote 10% here and justified it with a measurement: at 12% the
 * middle wash lands ΔE 4.0 from the LEGAL family's band, under the 4.5 two
 * meanings must keep apart. That was true of --amber-500 and is not true of
 * what this rule actually uses. --amber-700 is far darker, its wash is
 * nowhere near legal's, and family separation turns out never to bind at all:
 * it stays above ΔE 5.9 at every strength up to 28%.
 *
 * The real constraint is the one I had not thought to look for — the score
 * number sits ON this wash, in the same colour the wash is mixed from, so
 * making the card more colourful walks its own number toward it:
 *
 *     wash    number on its own card      neutral ink on it
 *      12%           4.72:1                     5.36:1
 *      14%           4.60:1                     5.22:1   <- last passing step
 *      16%           4.44:1                     5.05:1   <- under AA
 *
 * So the amber ceiling is 14%, and 12% ships — one step below it. This file
 * has twice been bitten by a value sitting exactly on a threshold (see
 * --ink-500 and --amber-700), and a 0.22 margin is worth one percent of wash.
 *
 * Each band has its own ceiling, because the constraint is a colour's contrast
 * against itself and the three colours differ. Teal has the most headroom and
 * carries the strongest wash; clay is deliberately far below its own ceiling
 * for the reason given above the band list. The guard measures each band
 * against its OWN --wash, read out of these rules — see "the score colours" in
 * lens-palette.test.js — so none of these three numbers can drift past the
 * point where a person can no longer read the score printed on the card.
 *
 * The gradient runs in the BLOCK direction on purpose. An inline-direction
 * wash would have to flip for Arabic and CSS gradients take no logical
 * direction; block flow is top-to-bottom in both languages, so this needs no
 * RTL special case. --wash is the top end, which is why that is the figure
 * measured. */
.jobcard[data-band] {
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--band) var(--wash), var(--surface)),
    color-mix(in srgb, var(--band) 3%, var(--surface))
  );
  border-color: color-mix(in srgb, var(--band) var(--edge), var(--line));
}

/* The rail stops being a hover flourish and becomes the ranking. A column of
   these reads as a scale from across the room; the numbers confirm it rather
   than announce it. 5.5:1 at the weakest of the three, against a non-text
   minimum of 3.0. */
.jobcard[data-band]::before {
  background: var(--band);
  opacity: 1;
  inline-size: 4px;
}
.jobcard[data-band]:hover { border-color: var(--band); }

/* Unranked lists — all jobs, and an employer's postings with nobody waiting —
   carry no band, and keep the neutral card and the hover-only rail. That
   difference is a fact about the list: this one is not sorted by fit.

   The employer's postings were once in that sentence without qualification,
   and the qualification is the point. What may not go on a date-sorted list is
   a *scale*: three strengths reading down the page as an order the list does
   not have. A single marker is not a scale, so "someone is waiting on this
   one" is allowed and "this one scores 63%" still is not. */

/* --- Two up ---------------------------------------------------------------
   The same move that shortened the skill list: a column of full-width bars
   wastes a 1140px page on a 60-character title. 27rem is the width below
   which the card's own flex-wrap starts stacking its parts, so the breakpoint
   is the content's, not a number somebody remembered.

   min(100%, 27rem), not 27rem: bare `minmax(27rem, 1fr)` cannot shrink below
   its floor, so any container narrower than 27rem overflows sideways instead
   of falling to one column. That is the auto-fit trap, and this page has a
   360px overflow test that would have caught it. */
.joblist { gap: .85rem; }

/* ...but only once there are enough cards for the height to be the problem.
   The match feed is thirteen jobs and two columns halve it. "My applications"
   is three, and the same rule left one card alone beside a wide empty hole —
   the grid solving a problem that list did not have.

   :has(> :nth-child(4)) is the condition stated as what it actually is: four
   or more children. A media query cannot ask this, because the answer depends
   on the content and not on the viewport. */
.joblist:has(> :nth-child(4)) {
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 27rem), 1fr));
}

/* The card lists on the employer per-job screens sit inside a .card--flush,
   which has no padding of its own so its filter bar can meet the edges. */
.cardhost { padding: 1rem; }

/* --- What a gap is worth --------------------------------------------------
   The figure rides on the chip, so it is read with the skill rather than
   looked up against it. Tabular numerals because a column of these is meant to
   be compared; the tint is the chip's own so the pair still reads as one
   object rather than a label with a badge stuck to it. */
.chip__gain {
  margin-inline-start: .35rem;
  padding-inline-start: .35rem;
  border-inline-start: 1px solid currentColor;
  font-variant-numeric: tabular-nums;
  font-size: .92em;
  font-weight: 700;
  opacity: .85;
}

/* The sentence above the gaps. --clay-050 rather than a plain card: it belongs
   to the gaps below it, and a neutral panel would read as a third section
   between the two lists. */
.nextmove {
  margin-block: .1rem .7rem;
  padding: .6rem .8rem;
  border-radius: var(--r-md);
  background: var(--clay-050);
  border-inline-start: 3px solid var(--clay-500);
  color: var(--ink-700);
  font-size: .95rem;
}

/* --- A person on a card ---------------------------------------------------
 *
 * The applicant and sourcing lists were tables of eight columns in 1080px.
 * They reuse .jobcard now, which brings the score band with it, plus three
 * things a person needs that a posting does not.
 */

/* The skills get their own line rather than sharing the meta's. In the table
   they lived in a ~180px column where each chip took a line of its own, so
   four skills cost 140px of height while the column beside them sat empty. */
.jobcard__skills {
  margin-block-start: .55rem;
  display: flex;
  flex-direction: column;
  gap: .35rem;
}

/* The label is what stops teal-vs-clay being the only thing that says whether
   a candidate HAS a skill or LACKS it. It is small and quiet, but it is text,
   and it is the reason this card is readable to someone who cannot separate
   the two hues. See personSkills() in views/employer.js. */
/* nowrap, and this is the whole reason: with wrap, a label sat BESIDE its
   chips when they were narrow and ABOVE them when they were wide, so two
   candidates in the same list had their evidence at different indents. The
   chips wrap internally instead — .chips is its own flex row — and
   min-inline-size:0 is what lets that inner row shrink rather than forcing
   the label off its line. */
.skillline {
  display: flex;
  gap: .5rem;
  align-items: baseline;
  flex-wrap: nowrap;
}
.skillline .chips { min-inline-size: 0; flex: 1 1 auto; }
.skillline__label {
  color: var(--ink-500);
  font-size: .74rem;
  font-weight: 600;
  letter-spacing: .03em;
  text-transform: uppercase;
  flex: 0 0 auto;
  /* Both labels share a column so the two chip rows start at the same place.
     6.5rem clears the longer of the two in either language. */
  min-inline-size: 6.5rem;
}

@media (max-width: 560px) {
  /* At phone width a 6.5rem gutter is a fifth of the screen. The label takes
     its own line instead, which costs height and buys the chips their width. */
  .skillline { flex-direction: column; gap: .2rem; }
  .skillline__label { min-inline-size: 0; }
}

/* The controls stack instead of sitting in the flex row. A select and a button
   side by side are the widest thing on the card, and at two columns they were
   what forced the body to wrap first. */
.jobcard__actions {
  display: flex;
  flex-direction: column;
  gap: .45rem;
  align-items: stretch;
}
.jobcard__actions .btn { justify-content: center; }

/* A person's card is taller than a posting's, so it aligns to the top rather
   than centring — a 100px card and a 200px card side by side with centred
   gauges put the two scores at different heights, which is exactly the
   comparison this screen exists to make easy. */
.jobcard--person { align-items: flex-start; }
.jobcard--person .gauge { margin-block-start: .15rem; }

/* The applicant count leads its card's meta line, so it is the one item there
   that is not the same weight as the rest. --ink-700 and not --ink-500: this
   is the fact the employer came to read, and it should not look like the date.
   The "none yet" state stays at the meta's own weight — an absence should not
   shout. */
.jobcard__count { color: var(--ink-700); font-weight: 600; }
.jobcard__count--none { color: var(--ink-500); font-weight: 400; }

/* --- Page heads ----------------------------------------------------------- */
.page-head { margin-block-end: 1.4rem; }
.page-head__sub { color: var(--ink-500); font-size: 1rem; }

@media (prefers-reduced-motion: reduce) {
  .btn, .jobcard { transition: none; }
  .btn--primary:hover:not(:disabled), .jobcard:hover { transform: none; }
}

/* ==========================================================================
   A coloured ground, and room to breathe
   ==========================================================================
   Four things asked for together: stronger colour everywhere rather than
   white, illustration, a violet-blue gradient behind the pages, and larger
   cards with more space between them and less dense text.

   ONE HONEST NOTE ON THE VIOLET. A violet-to-blue gradient on white is the
   most-produced look in interface design, and copying it wholesale would make
   this product resemble a thousand others. So the gradient is mixed from the
   hues this product ALREADY owns — --fp-3 (#6b8afd) and --fp-4 (#a97bf5) are
   two of the six that draw the fingerprint and colour the skill families. It
   is a violet that belongs here rather than one borrowed from a template.
   ========================================================================== */

:root {
  /* The lightest stop of the ground, named because text sits on it and
     therefore because the contrast test has to know about it. Adding a
     surface without adding it there is how a palette guard quietly stops
     guarding the thing it was written for. */
  /* Was #eff0fa, the top stop of the violet rainbow this ground used to be.
     Now biased toward the teal the product means "held" with, which is the
     difference between a neutral that was chosen and one that was inherited. */
  --paper-tint: #edf1f0;
}

/* The ground moved again and now lives in one place, further down this file —
   see "THE GROUND, THIRD TIME". The declaration that stood here was already
   dead, overridden by a later one, which is how three of them accumulated
   without anybody noticing there was more than one. */

/* --- Room -----------------------------------------------------------------
   Cards get bigger, further apart, and rounder, so a page reads as a few
   objects rather than as a wall of text. Nothing here changes what is said —
   only how much air surrounds it. */
/* --card-pad is published rather than written twice: a child that needs to
   reach the card's edge has to cancel exactly this, and a hard-coded copy of it
   is a copy that stops matching the day the padding changes. See `.card .lens`. */
.card { --card-pad: 1.75rem; padding: var(--card-pad); border-radius: 24px; }
.grid { gap: 1.5rem; }
.stack { gap: 1.1rem; }
.page-head { margin-block-end: 2rem; }
.lensgroups { gap: 1.9rem; }

/* Cards float on the colour rather than sitting flat on it: a light surface,
   a soft ring instead of a hard border, and a real shadow so the ground shows
   through around them. */
.card {
  background: linear-gradient(170deg, rgba(255, 255, 255, .96), rgba(255, 255, 255, .88));
  border: 1px solid rgba(255, 255, 255, .9);
  box-shadow:
    0 1px 2px rgba(28, 33, 68, .05),
    0 18px 40px -20px rgba(28, 33, 68, .28);
  backdrop-filter: blur(6px);
}

.fp { padding: 2.2rem 2.4rem; border-radius: 26px; }

/* ==========================================================================
   THE OPENING BAND
   ==========================================================================
   Both app screens were called flat, and this is the answer to it: the band
   the candidate's fingerprint already sat on becomes the product's one
   structural idea, and the employer gets it too — including, and especially,
   when they have posted nothing.

   The four counts moved onto it. On the pale ground they were four pale cards
   showing four zeroes, which is a page of empty containers; the tiles below
   are the dark version of the same component, so the numbers keep their
   meaning and gain a ground worth reading them on.
   ========================================================================== */

/* The tiles, on ink. */
.fp__tiles {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
  gap: .7rem;
  margin-block-start: 1.4rem;
}

.fp__tiles .stat {
  background: rgba(255, 255, 255, .055);
  border: 1px solid rgba(255, 255, 255, .10);
  box-shadow: none;
  backdrop-filter: blur(3px);
}
.fp__tiles .stat:hover { box-shadow: none; border-color: rgba(255, 255, 255, .2); }
/* 18.10:1 and 10.29:1 on this ink — the same two values the sign-in screen's
   text and secondary text are measured at, because it is the same ink. */
.fp__tiles .stat__value { color: #f2f7f5; }
.fp__tiles .stat__label { color: #a9c1bb; }

/* The band an employer sees before they have posted anything. One column, not
   two: there is no figure, and a lone text block in the second cell of a
   figure-and-facts grid would sit oddly far from the edge. */
.fp--empty { grid-template-columns: minmax(0, 1fr); min-block-size: 15rem; align-content: center; }
.fp--empty .fp__facts { max-inline-size: 42ch; }
.fp__lede {
  margin: .6rem 0 1.3rem;
  color: #a9c1bb;
  font-size: 1rem;
  line-height: 1.6;
}
.fp--empty .btn--primary {
  background: var(--fp-1);
  color: #04140f;                 /* 11.69:1, the same measured pair as #/auth */
  font-weight: 600;
  border-color: transparent;
}
.fp--empty .btn--primary:hover:not(:disabled) {
  background: color-mix(in srgb, var(--fp-1) 86%, #fff);
}

/* What happens after you post. The numbers are the point rather than a
   decoration — the second step cannot happen before the first — which is the
   only reason to number anything. */
.steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 1.5rem;
  counter-reset: none;
}
.step { display: grid; gap: .3rem; align-content: start; }
.step__n {
  inline-size: 2rem;
  block-size: 2rem;
  display: grid;
  place-items: center;
  margin-block-end: .35rem;
  border-radius: 50%;
  background: var(--teal-050);
  color: var(--teal-900);
  font-weight: 700;
  font-size: .92rem;
}
.step__title { margin: 0; font-size: 1.02rem; }
.step__sub { margin: 0; color: var(--ink-500); font-size: .92rem; line-height: 1.6; }

/* The three things that cause a CV to read as empty. A list, because they are
   alternatives to check rather than a sequence to follow — which is why it is
   not the numbered `.steps` above. */
.reasons {
  margin: 1.1rem 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: .8rem;
}
.reasons li {
  position: relative;
  padding-inline-start: 1.5rem;
  color: var(--ink-700);
  line-height: 1.65;
}
.reasons li::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  inset-block-start: .62em;
  inline-size: 7px;
  block-size: 7px;
  border-radius: 50%;
  background: var(--teal-500);
}

/* --- The drawing ----------------------------------------------------------
   See artworkExtract() for why this is geometry rather than a character. */
.art { display: block; margin: 0 auto .9rem; max-inline-size: 100%; }
.art__page { fill: color-mix(in srgb, var(--fp-3) 12%, #fff); stroke: color-mix(in srgb, var(--fp-3) 34%, #fff); stroke-width: 1.5; }
.art__line { fill: color-mix(in srgb, var(--fp-3) 26%, #fff); }
.art__trace { fill: none; stroke: color-mix(in srgb, var(--fp-4) 40%, #fff); stroke-width: 1.6; stroke-dasharray: 3 5; stroke-linecap: round; }
.art__dot { opacity: .95; }

@media (prefers-reduced-motion: no-preference) {
  .art__dot {
    animation: art-pop .5s cubic-bezier(.22, 1, .36, 1) backwards;
    animation-delay: calc(var(--i, 0) * 70ms);
    transform-origin: center;
  }
}
@keyframes art-pop {
  from { transform: scale(.2) translateY(8px); opacity: 0; }
  to   { transform: none; opacity: .95; }
}

/* --- The drop zone, now the page's illustration ---------------------------
   THE BORDER IS THE CONTROL. There is no button here and no filled surface
   that separates from the card behind it — the dashed edge is the only thing
   saying "this is where the file goes", which makes it non-text UI information
   and puts it at WCAG 1.4.11's 3.0:1.

   It was at 1.74:1 on white and 1.56:1 on the sunk card, and in a screenshot
   at full width it read as a couple of stray arcs at the corners with nothing
   between them — which is exactly what a 1.74:1 line looks like. `--fp-3`
   itself does not fix it: measured, the raw token reaches 3.14 on white but
   only 2.81 on the sunk card and 2.76 on the page tint, so it fails on two of
   the four grounds this control sits on.

   Mixed 85% toward --ink-900 the worst ground is 3.48:1, and it is still
   plainly the product's blue. lens-palette.test.js measures it against all
   four so a future lightening for looks cannot put it back under. */
.dropzone {
  border: 2px dashed color-mix(in srgb, var(--fp-3) 85%, var(--ink-900));
  border-radius: 20px;
  background: linear-gradient(170deg,
    color-mix(in srgb, var(--fp-3) 9%, var(--surface)),
    color-mix(in srgb, var(--fp-4) 6%, var(--surface)));
  padding: 1.6rem 1.4rem;
  transition: border-color .16s ease, background-color .16s ease;
}
.dropzone.is-over,
.dropzone:hover {
  border-color: var(--fp-3);
  background: linear-gradient(170deg,
    color-mix(in srgb, var(--fp-3) 16%, var(--surface)),
    color-mix(in srgb, var(--fp-4) 11%, var(--surface)));
}
.dropzone__title { font-size: 1.05rem; font-weight: 600; }

/* --- The signed-out screen ------------------------------------------------
   The first thing every visitor sees, and previously the plainest page in the
   product: a white box on white. */
.auth {
  min-block-size: 74vh;
  display: grid;
  place-items: center;
  padding-block: 2rem;
}
.auth__card {
  inline-size: min(30rem, 100%);
  padding: 2.4rem 2.2rem;
  border-radius: 26px;
  background: rgba(255, 255, 255, .93);
  border: 1px solid rgba(255, 255, 255, .9);
  box-shadow:
    0 1px 2px rgba(28, 33, 68, .06),
    0 30px 60px -28px rgba(28, 33, 68, .40);
  backdrop-filter: blur(8px);
}
.auth__lede { color: var(--ink-500); }

@media (max-width: 640px) {
  .card { padding: 1.25rem; border-radius: 20px; }
  .auth__card { padding: 1.6rem 1.3rem; }
  .grid { gap: 1.1rem; }
}

@media (prefers-reduced-motion: reduce) {
  .dropzone { transition: none; }
}

/* ==========================================================================
   Living graphics
   ==========================================================================
   Ambient motion, turned up.

   ONE RULE GOVERNS ALL OF IT: nothing that runs forever may touch a letter.
   Every continuous animation below moves a background, an SVG shape or a glow,
   and never text, opacity on a text node, or a colour a reader has to resolve.
   The reason is measured rather than principled — the accessibility suite reads
   rendered pixels, and a string whose contrast is mid-transition reports a
   colour the stylesheet does not contain. That produced 91 phantom violations
   once and cost a day to trace.

   Infinite animations are also invisible to the suite's settle helper, which
   waits only on finite ones, so ambient motion cannot hang a scan.
   ========================================================================== */

/* --- The ground breathes -------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  body::before {
    animation: ground-drift 34s ease-in-out infinite alternate;
    will-change: transform;
  }
}
@keyframes ground-drift {
  from { transform: scale(1) translate3d(0, 0, 0); }
  to   { transform: scale(1.12) translate3d(-2%, 1.5%, 0); }
}

/* ==========================================================================
   THE GROUND, THIRD TIME — and the first two are why this comment is long
   ==========================================================================
   Three `body::before` background declarations had piled up in this file, each
   overriding the last, each answering "make it more colourful" with one more
   radial. The one that shipped had five, from blue at .46 through violet,
   cyan and green to pink: a rainbow at high alpha behind every screen in the
   product. It read as a template, and it was the first thing named when the
   sign-in screen was called good and the rest of the app was not.

   FIVE LIGHTS ARE NOT A ROOM. The note on the sign-in panel's glow makes the
   argument and this ground ignored it: one centred glow reads as a spotlight,
   two off-centre ones read as a room, and five read as a colour picker. So
   there are two here, both from hues this product already owns — the teal that
   means "held" and the blue that draws a third of the fingerprint — over a
   neutral carrying a faint bias toward the accent rather than a dead grey.

   Quiet on purpose. The colour in this product belongs to the dark hero bands,
   where the expressive ramp is luminous and saturation is free; a page ground
   competing with them leaves nowhere for the eye to go. */
body::before {
  background:
    radial-gradient(110% 70% at 50% -18%, rgba(13, 110, 99, .13), transparent 64%),
    radial-gradient(72% 52% at 94% 4%, rgba(107, 138, 253, .09), transparent 68%),
    /* The token, never a literal: text sits on this stop, so the contrast
       guard has to be measuring the colour the page actually paints. */
    linear-gradient(180deg, var(--paper-tint), var(--paper) 52%);
}

/* The sign-in screen is ink from edge to edge, and this fixed layer sits above
   `body`'s own background — so darkening the body alone left the ground
   showing as a pale band under the form, which is what a screenshot caught. */
body:has(.authstage)::before { display: none; }

/* --- The fingerprint turns, and breathes ---------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  .fp__svg {
    animation: fp-turn 90s linear infinite;
    transform-origin: 50% 50%;
  }
  /* The rings counter-rotate, so the figure reads as two layers rather than
     one picture spinning. */
  .fp__ring {
    animation: fp-turn-back 60s linear infinite;
    transform-origin: 120px 120px;
  }
  .fp__spoke {
    animation:
      fp-sweep .5s cubic-bezier(.22, 1, .36, 1) backwards,
      fp-breathe 4.5s ease-in-out infinite;
    animation-delay: calc(var(--i, 0) * 22ms), calc(var(--i, 0) * 60ms);
  }
  .fp__dot {
    animation:
      fp-sweep .5s cubic-bezier(.22, 1, .36, 1) backwards,
      fp-twinkle 3.6s ease-in-out infinite;
    animation-delay: calc(var(--i, 0) * 22ms), calc(var(--i, 0) * 90ms);
  }
  /* A slow light sweeping across the hero. */
  .fp::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(105deg, transparent 38%, rgba(255,255,255,.10) 48%, transparent 58%);
    animation: hero-sheen 9s ease-in-out infinite;
    pointer-events: none;
  }
}

@keyframes fp-turn { to { transform: rotate(360deg); } }
@keyframes fp-turn-back { to { transform: rotate(-360deg); } }
@keyframes fp-breathe {
  0%, 100% { opacity: .92; stroke-width: 3; }
  50%      { opacity: 1;   stroke-width: 3.8; }
}
@keyframes fp-twinkle {
  0%, 100% { opacity: .95; r: 2.6; }
  50%      { opacity: .7;  r: 3.4; }
}
@keyframes hero-sheen {
  0%   { transform: translateX(-120%); }
  55%  { transform: translateX(120%); }
  100% { transform: translateX(120%); }
}

/* --- Marks travelling the artwork's trace --------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  .art__trace {
    stroke-dasharray: 4 6;
    animation: trace-run 2.4s linear infinite;
  }
  .art__dot {
    animation:
      art-pop .5s cubic-bezier(.22, 1, .36, 1) backwards,
      art-float 4s ease-in-out infinite;
    animation-delay: calc(var(--i, 0) * 70ms), calc(var(--i, 0) * 180ms);
  }
  .art__page { animation: page-tilt 7s ease-in-out infinite alternate; transform-origin: 52px 70px; }
}
@keyframes trace-run { to { stroke-dashoffset: -20; } }
@keyframes art-float {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-5px); }
}
@keyframes page-tilt {
  from { transform: rotate(-1.2deg); }
  to   { transform: rotate(1.2deg); }
}

/* --- Skill meters fill rather than appear --------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  .lensgroup .meter__fill {
    animation: meter-fill .7s cubic-bezier(.22, 1, .36, 1) backwards;
    animation-delay: calc(var(--i, 0) * 45ms + 120ms);
  }
}
@keyframes meter-fill { from { transform: scaleX(0); } to { transform: none; } }
.meter__fill { transform-origin: inline-start center; }

/* --- Tiles lift under the pointer ----------------------------------------- */
.stat { transition: transform .18s cubic-bezier(.22, 1, .36, 1), box-shadow .18s ease; }
.stat:hover {
  transform: translateY(-3px);
  box-shadow: 0 14px 30px -14px color-mix(in srgb, var(--tone) 55%, transparent);
}
.stat__icon { transition: transform .18s cubic-bezier(.22, 1, .36, 1); }
.stat:hover .stat__icon { transform: scale(1.08) rotate(-4deg); }

@media (prefers-reduced-motion: reduce) {
  .stat, .stat__icon { transition: none; }
  .stat:hover { transform: none; }
  .stat:hover .stat__icon { transform: none; }
}

/* ==========================================================================
   Skills as a mosaic, not a ledger
   ==========================================================================
   The list was the last plain thing on the page and by far the largest: 26
   rows of small text down a single column, which is four fifths of what a
   candidate actually looks at. Colour at the top of a page cannot rescue a
   wall of rows underneath it.

   Each skill becomes a card in a two-column grid, carrying its family's colour
   as a real edge, a thicker gradient meter, and the evidence line it already
   had. Same information, same order, same evidence — laid out so the eye can
   land somewhere.
   ========================================================================== */

/* The rail moves onto the cards themselves, so the group no longer needs one. */
.lensgroup .stack {
  border-inline-start: 0;
  padding-inline-start: 0;
  margin-inline-start: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(255px, 1fr));
  gap: .7rem;
  align-items: stretch;
}

.skillrow {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: .35rem .8rem;
  align-content: start;
  margin: 0;
  padding: .8rem .9rem .8rem 1.05rem;
  border: 1px solid color-mix(in srgb, var(--g, var(--teal-500)) 26%, var(--line-soft));
  border-radius: 14px;
  background:
    linear-gradient(155deg,
      color-mix(in srgb, var(--g, var(--teal-500)) 9%, var(--surface)),
      var(--surface) 65%);
  position: relative;
  overflow: hidden;
  transition: transform .16s cubic-bezier(.22, 1, .36, 1), box-shadow .16s ease;
}

/* The family edge. On the card rather than the group, so a skill still says
   which family it belongs to when the cards wrap into two columns and the
   group's own heading is a row away. */
.skillrow::after {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: 3px;
  background: var(--g, var(--teal-500));
}

.skillrow:hover {
  background:
    linear-gradient(155deg,
      color-mix(in srgb, var(--g, var(--teal-500)) 16%, var(--surface)),
      var(--surface) 70%);
  transform: translateY(-2px);
  box-shadow: 0 12px 26px -14px color-mix(in srgb, var(--g, var(--teal-500)) 60%, transparent);
}

.skillrow__name { font-size: .98rem; font-weight: 600; }

/* Thicker, and a gradient rather than a flat fill, so it reads as a measure
   at a glance instead of as a hairline somebody has to look for. */
.lensgroup .meter {
  inline-size: 58px;
  block-size: 7px;
  transform: none;
  align-self: center;
  background: color-mix(in srgb, var(--g, var(--teal-500)) 16%, var(--line-soft));
}
.lensgroup .meter__fill {
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--g, var(--teal-500)) 65%, #fff),
    var(--g, var(--teal-500)));
}

.skillrow__evidence {
  grid-column: 1 / -1;
  font-size: .78rem;
  margin-block-start: .15rem;
  padding-block-start: .4rem;
  border-block-start: 1px dashed color-mix(in srgb, var(--g, var(--teal-500)) 24%, var(--line-soft));
}

/* The group heading gets the family colour as a soft plate, so the six
   sections are legible as sections while scrolling. */
.lensgroup__head {
  border-block-end: 0;
  padding: .35rem .7rem;
  border-radius: 10px;
  background: color-mix(in srgb, var(--g, var(--teal-500)) 12%, transparent);
  margin-block-end: .7rem;
}

@media (prefers-reduced-motion: reduce) {
  .skillrow { transition: none; }
  .skillrow:hover { transform: none; }
}

/* The mosaic costs height on a phone, where every card is its own row and each
   one carries its own padding. Measured: the plain list was 3741px on a 360px
   screen and the first version of the cards was 5281px — a 41% longer scroll,
   paid for a layout that cannot show two columns there anyway.
   Tightened back: the cards keep their colour and their edge, and give up the
   padding that was only ever buying breathing room beside a neighbour. */
@media (max-width: 560px) {
  .lensgroup .stack { gap: .45rem; }
  .skillrow { padding: .6rem .7rem .6rem .85rem; border-radius: 12px; }
  .skillrow__evidence { padding-block-start: .3rem; margin-block-start: .1rem; }
  .lensgroups { gap: 1.3rem; }
}

/* ==========================================================================
   THE FRONT DOOR
   ==========================================================================
   The one screen a visitor meets before having an account, and the one place
   in this product where a loud visual language belongs. The applicant list and
   the requirement editor are places people do a job; a front door exists to be
   looked at.

   ON THE DARK GROUND, because it is the decision everything else follows from.
   The expressive ramp above says of itself: "spokes, glows, gradients and
   swatches — shapes on a dark ground, where saturation is free and contrast is
   not a question a reader's eyes have to answer." It had never been given a
   dark ground to sit on. Measured on #0a1614 the whole ramp lands between
   5.87:1 and 11.74:1, so this is finally the surface it was designed for.

   And no new palette is imported. The accents are teal and clay — the two
   poles that already mean "held" and "gap" on every other screen — so the
   front door and the product agree about what a colour means.
   ========================================================================== */
.lp-hero,
.lp-ticker {
  --lp-ink: #0a1614;
  --lp-text: #f2f7f5;
  --lp-muted: #c3d6d1;
}

/* The chips are outlines now, so this surface has to say what they are drawn in.
 *
 * When a chip carried its own pale fill, its dark text sat on that fill wherever
 * the chip went — including here, inside the hero's demo card, on near-black.
 * Dropping the fill let this ground through, and --clay-700 on #0a1614 measures
 * 3.19:1. axe caught it on the front door and nowhere else, because this is the
 * only place in the product that puts a chip on ink.
 *
 * That is not an argument against the outline; it is the ordinary cost of one:
 * a transparent thing inherits its ground, so every ground it can land on has
 * to name its colours. This is the only other one. Same hue, stepped light
 * enough to read: the chip still says "gap" here exactly as it does on a card. */
.lp-hero .chip--gap {
  color: var(--clay-100);
  border-color: var(--clay-500);
}

.lp-hero {
  position: relative;
  padding: clamp(3rem, 8vw, 6rem) 1.15rem clamp(3.5rem, 9vw, 6.5rem);
  background: var(--lp-ink);
  color: var(--lp-text);
  overflow: hidden;
  isolation: isolate;
}

/* Two lights rather than one, and they are the two poles: teal where the
   figure sits, clay on the opposite corner. A single centred glow reads as a
   spotlight; two off-centre ones read as a room. */
.lp-hero__glow {
  position: absolute;
  inset: -30%;
  z-index: -1;
  background:
    radial-gradient(38% 42% at 72% 30%, color-mix(in srgb, var(--fp-1) 26%, transparent), transparent 70%),
    radial-gradient(34% 38% at 18% 78%, color-mix(in srgb, var(--clay-500) 22%, transparent), transparent 68%),
    radial-gradient(46% 40% at 40% 8%, color-mix(in srgb, var(--fp-3) 14%, transparent), transparent 72%);
  animation: lpDrift 24s ease-in-out infinite alternate;
}
@keyframes lpDrift {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(2%, -3%, 0) scale(1.1); }
}

.lp-hero__in {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr);
  gap: clamp(2rem, 5vw, 4rem);
  align-items: center;
  max-inline-size: 1140px;
  margin-inline: auto;
}

.lp-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  margin: 0 0 1.2rem;
  padding: .35rem .9rem;
  border-radius: var(--r-pill);
  border: 1px solid color-mix(in srgb, var(--lp-text) 16%, transparent);
  background: color-mix(in srgb, var(--lp-text) 7%, transparent);
  color: var(--lp-muted);
  font-size: .84rem;
  font-weight: 600;
}
.lp-eyebrow__dot {
  inline-size: 8px;
  block-size: 8px;
  border-radius: 50%;
  background: var(--fp-1);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--fp-1) 22%, transparent);
}

.lp-h1 {
  margin: 0;
  font-size: clamp(2.1rem, 5.2vw, 3.6rem);
  line-height: 1.14;
  letter-spacing: -.02em;
  text-wrap: balance;
}
.lp-h1__line { display: block; }
.lp-h1__grad {
  background: linear-gradient(100deg, var(--fp-1), var(--fp-2) 45%, var(--fp-3));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.lp-lead {
  margin: 1.1rem 0 0;
  max-inline-size: 54ch;
  color: var(--lp-muted);
  font-size: clamp(1rem, 1.3vw, 1.12rem);
  line-height: 1.75;
}

.lp-actions { display: flex; flex-wrap: wrap; gap: .8rem; margin-block-start: 1.9rem; }

/* The ghost button was drawn for a light card. On ink it needs its own skin,
   or it is a transparent rectangle with a nearly invisible edge. */
.lp-hero .btn--ghost {
  background: color-mix(in srgb, var(--lp-text) 10%, transparent);
  border-color: color-mix(in srgb, var(--lp-text) 22%, transparent);
  color: var(--lp-text);
}
.lp-hero .btn--ghost:hover { background: color-mix(in srgb, var(--lp-text) 16%, transparent); }

.lp-stats {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(1.2rem, 4vw, 2.6rem);
  margin: 2.4rem 0 0;
  padding-block-start: 1.6rem;
  border-block-start: 1px solid color-mix(in srgb, var(--lp-text) 14%, transparent);
}
.lp-stat__v {
  font-size: 1.75rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  background: linear-gradient(120deg, var(--fp-1), var(--fp-2));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.lp-stat__l { margin: .1rem 0 0; color: var(--lp-muted); font-size: .84rem; max-inline-size: 18ch; }

/* --- the figure, and the two glass cards that read it --------------------- */
.lp-hero__art { position: relative; display: grid; place-items: center; min-block-size: 320px; }
.lp-fig {
  inline-size: min(100%, 340px);
  filter: drop-shadow(0 24px 50px color-mix(in srgb, var(--fp-1) 22%, transparent));
  animation: lpFloat 9s ease-in-out infinite alternate;
}
.lp-fig .fp__svg { inline-size: 100%; block-size: auto; }
@keyframes lpFloat { from { transform: translateY(0); } to { transform: translateY(-14px); } }

.lp-glass {
  position: absolute;
  padding: .8rem 1rem;
  border-radius: var(--r-lg);
  border: 1px solid color-mix(in srgb, var(--lp-text) 16%, transparent);
  background: color-mix(in srgb, var(--lp-ink) 62%, transparent);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  box-shadow: 0 20px 44px -20px rgba(0, 0, 0, .7);
  animation: lpFloat 7s ease-in-out infinite alternate;
}
.lp-glass--score {
  inset-block-start: 6%;
  inset-inline-end: 0;
  display: flex;
  align-items: baseline;
  gap: .15rem;
  flex-wrap: wrap;
}
.lp-glass--gap { inset-block-end: 4%; inset-inline-start: 0; animation-duration: 8.5s; }
.lp-glass__num {
  font-size: 1.7rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--fp-1);
}
.lp-glass__unit { color: var(--fp-1); font-size: .9rem; }
.lp-glass__label {
  display: block;
  inline-size: 100%;
  color: var(--lp-muted);
  font-size: .74rem;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
}

/* --- the ticker ----------------------------------------------------------- */
.lp-ticker {
  padding-block: .9rem;
  background: color-mix(in srgb, var(--lp-ink) 96%, var(--fp-3));
  color: var(--lp-muted);
  overflow: hidden;
}
.lp-ticker__track { display: flex; inline-size: max-content; animation: lpSlide 38s linear infinite; }
.lp-ticker__row { display: flex; gap: 2.4rem; padding-inline-end: 2.4rem; }
.lp-ticker__item { white-space: nowrap; font-size: .9rem; font-weight: 600; }
.lp-ticker__item::before {
  content: "\25C6";
  margin-inline-end: .7rem;
  color: var(--fp-1);
  font-size: .6em;
  vertical-align: middle;
}
@keyframes lpSlide { to { transform: translateX(-50%); } }

/* RTL runs the other way. A strip sliding against the reading direction reads
   as an error rather than as motion. */
[dir="rtl"] .lp-ticker__track { animation-name: lpSlideRtl; }
@keyframes lpSlideRtl { to { transform: translateX(50%); } }

/* --- the light half ------------------------------------------------------- */
.lp-section { padding-block: clamp(3rem, 7vw, 5rem); }
.lp-head { max-inline-size: 62ch; margin-block-end: 2.2rem; }
.lp-h2 { font-size: clamp(1.5rem, 3vw, 2.1rem); line-height: 1.25; text-wrap: balance; }
.lp-sub { margin: .7rem 0 0; color: var(--ink-500); }

.lp-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  gap: 1.1rem;
}
.lp-card {
  padding: 1.5rem;
  border-radius: var(--r-lg);
  border: 1px solid var(--line);
  background: var(--surface);
  box-shadow: var(--shadow-sm);
  transition: transform .3s ease, box-shadow .3s ease;
}
.lp-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
.lp-card__ico {
  display: grid;
  place-items: center;
  inline-size: 46px;
  block-size: 46px;
  border-radius: 14px;
  margin-block-end: 1rem;
  color: #fff;
}
.lp-card[data-tone="1"] .lp-card__ico { background: linear-gradient(135deg, var(--teal-700), var(--fp-1)); }
.lp-card[data-tone="2"] .lp-card__ico { background: linear-gradient(135deg, var(--fp-3), var(--fp-4)); }
.lp-card[data-tone="3"] .lp-card__ico { background: linear-gradient(135deg, var(--clay-700), var(--fp-6)); }
.lp-card__t { font-size: 1.08rem; margin-block-end: .4rem; }
.lp-card__d { color: var(--ink-500); font-size: .95rem; }

.lp-steps {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
  gap: 1.4rem;
  margin: 0;
  padding: 0;
  list-style: none;
}
.lp-step__n {
  display: grid;
  place-items: center;
  inline-size: 44px;
  block-size: 44px;
  border-radius: 14px;
  margin-block-end: .8rem;
  background: color-mix(in srgb, var(--teal-500) 12%, var(--surface));
  border: 1px solid color-mix(in srgb, var(--teal-500) 28%, var(--line));
  color: var(--teal-700);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.lp-step__t { font-size: 1rem; margin-block-end: .3rem; }
.lp-step__d { color: var(--ink-500); font-size: .92rem; }

.lp-closer {
  margin-block: clamp(2rem, 5vw, 3.5rem) 2rem;
  padding: clamp(2rem, 5vw, 3.2rem);
  border-radius: var(--r-lg);
  text-align: center;
  background: linear-gradient(150deg, var(--teal-900), #0a1614);
  color: #f2f7f5;
}
.lp-closer .lp-sub { color: #c3d6d1; margin-inline: auto; max-inline-size: 52ch; }
.lp-closer .btn { margin-block-start: 1.6rem; }

@media (max-width: 860px) {
  .lp-hero__in { grid-template-columns: 1fr; }
  /* The glass cards hang off the figure absolutely. At this width the figure
     is centred and narrow and they would land on top of it, so they join the
     flow instead. */
  .lp-hero__art { min-block-size: 0; }
  .lp-glass { position: static; margin-block-start: 1rem; animation: none; }
}

@media (prefers-reduced-motion: reduce) {
  .lp-hero__glow, .lp-fig, .lp-glass, .lp-ticker__track { animation: none; }
  .lp-card:hover { transform: none; }
}

/* --- the bleed shell ------------------------------------------------------
   Only the landing uses this. .main is normally a centred 1180px column with
   its own padding; a full-width hero cannot exist inside one. Widening .main
   itself avoids viewport units entirely, and with them the scrollbar rounding
   that `calc(50% - 50vw)` introduces — this project measures overflow at
   360px, and that trick fails it. */
.main--bleed {
  max-width: none;
  padding-inline: 0;
  padding-block-start: 0;
}
.lp-wrap {
  max-inline-size: 1180px;
  margin-inline: auto;
  padding-inline: 1.15rem;
}

/* The fingerprint is the product's signature and the reason it has that name,
   so on the front door it is drawn heavier than in the app. In the dashboard
   the figure sits beside a panel of text and must not shout over it; here it
   IS the argument, and the first version rendered as a faint ring of hairlines
   that read as an ornament rather than as the thing being sold. */
.lp-fig .fp__spoke { stroke-width: 5; opacity: 1; }
.lp-fig .fp__ring { stroke: rgba(255, 255, 255, .16); }
.lp-fig .fp__dot { opacity: 1; }

/* ==========================================================================
   The pipeline track
   ==========================================================================
   Where an application has got to, drawn as the stages it passes through.

   WHAT IT REPLACED, and why the layout problem and the content problem had the
   same answer. `#/applications` was 44% empty at 1360x900, and each row carried
   about 700px of nothing between the date and a grey pill reading "Submitted".
   That pill was the flattest element on the screen and it was the one fact the
   candidate came to read: am I being looked at? Widening the row would have
   been arranging emptiness; this fills it with the answer.

   Nothing here is invented to fill space. Every stage is a value the API
   already returns, and the one a person sits on is the one their employer set.

   ON RTL: there is not a `left` or a `right` below. The connector is drawn with
   `inset-inline-end`, so the line runs from the previous dot to this one in
   both directions and the whole track mirrors with the document.
   ========================================================================== */
.track {
  --track-done: var(--teal-500);
  --track-line: var(--line);
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  align-items: flex-start;
  /* Takes the room the row was wasting, but stops well short of stretching
     three short words across a 1280px card. */
  flex: 1 1 auto;
  min-inline-size: 0;
  max-inline-size: 24rem;
}

.track__step {
  position: relative;
  flex: 1 1 0;
  min-inline-size: 0;
  display: grid;
  justify-items: center;
  gap: .35rem;
  text-align: center;
}

/* The connector belongs to the step it leads INTO, which is what lets a step
   fill its own line when it is reached. It spans from the previous dot's
   centre to this one's, so it is drawn at 50% of this step and one whole step
   wide. */
.track__step + .track__step::before {
  content: "";
  position: absolute;
  inset-block-start: 5px;
  inset-inline-end: 50%;
  inline-size: 100%;
  block-size: 2px;
  background: var(--track-line);
}
.track__step[data-state="done"] + .track__step::before,
.track__step[data-state="now"]::before { background: var(--track-done); }
.track__step[data-state="done"] + .track__step::before { background: var(--track-done); }

.track__dot {
  position: relative;
  z-index: 1;
  inline-size: 12px;
  block-size: 12px;
  border-radius: 50%;
  background: var(--surface);
  border: 2px solid var(--track-line);
}
.track__step[data-state="done"] .track__dot {
  background: var(--track-done);
  border-color: var(--track-done);
}
/* The stage a person is actually on: filled, and ringed so it reads as the
   one that matters among three otherwise identical marks. */
.track__step[data-state="now"] .track__dot {
  background: var(--track-done);
  border-color: var(--track-done);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-done) 22%, transparent);
}

.track__name {
  font-size: .74rem;
  line-height: 1.25;
  color: var(--ink-500);
  overflow-wrap: anywhere;
}
/* --ink-700 rather than --ink-900: the current stage should be the clearest of
   the three without becoming the loudest thing on a card whose score already
   leads. Measured on every surface a card can use, --ink-700 clears AA. */
.track__step[data-state="now"] .track__name { color: var(--ink-700); font-weight: 600; }

/* An application that ended. No progress bar — the product does not record
   which stage somebody was at when it happened, so a fourth dot would be
   inventing a position. Clay, which means "gap" everywhere else here. */
.track--out {
  display: flex;
  align-items: center;
  gap: .5rem;
  margin: 0;
  flex: 0 1 auto;
  color: var(--clay-700);
  font-size: .84rem;
  font-weight: 600;
}
.track--out .track__dot {
  inline-size: 10px;
  block-size: 10px;
  background: var(--clay-700);
  border-color: var(--clay-700);
}

@media (max-width: 640px) {
  /* On a phone the card stacks and the track would be three words squeezed
     into a third of 360px each. Full width, and the names get room. */
  .track { max-inline-size: none; inline-size: 100%; }
}

/* --- The queue on a posting card ------------------------------------------
   The employer's side of the pipeline: not one person's position, but the
   shape of a whole queue.

   ON THE COLOURS, which are borrowed rather than invented. This product owns
   three that already mean something about an outcome — teal for held, amber
   for the middle, clay for a gap — plus a neutral for "nothing has happened
   yet". They map onto the stages exactly: an untouched applicant is neutral,
   one being read is amber, a shortlisted one is teal, a declined one is clay.
   So an employer who has learned what a colour means on a score card already
   knows what it means here.

   The counts are printed as text beside the bar. Nobody should have to measure
   a bar to read a number, and it is what a screen reader gets. */
.queue {
  display: grid;
  gap: .3rem;
  flex: 0 1 15rem;
  min-inline-size: 0;
}

.queue__bar {
  display: flex;
  gap: 2px;
  block-size: 6px;
  border-radius: var(--r-pill);
  overflow: hidden;
  background: var(--surface-sunk);
}
/* flex-grow carries the share, set per segment in js. flex-basis 0 so the
   grow factor is the whole story and a segment's content cannot skew it. */
.queue__seg { flex-basis: 0; min-inline-size: 3px; background: var(--stage); }

.queue__keys {
  display: flex;
  flex-wrap: wrap;
  gap: .1rem .7rem;
  font-size: .72rem;
  color: var(--ink-500);
}
.queue__key { display: inline-flex; align-items: center; gap: .3rem; }
.queue__swatch {
  inline-size: 7px;
  block-size: 7px;
  border-radius: 2px;
  background: var(--stage);
  flex: 0 0 auto;
}

.queue__seg[data-stage="submitted"],
.queue__key[data-stage="submitted"] { --stage: var(--ink-300); }
.queue__seg[data-stage="reviewing"],
.queue__key[data-stage="reviewing"] { --stage: var(--amber-500); }
.queue__seg[data-stage="shortlisted"],
.queue__key[data-stage="shortlisted"] { --stage: var(--teal-500); }
.queue__seg[data-stage="rejected"],
.queue__key[data-stage="rejected"] { --stage: var(--clay-500); }

@media (max-width: 640px) {
  .queue { flex: 1 1 100%; }
}

/* ==========================================================================
   THE DOOR PEOPLE ACTUALLY WALK THROUGH
   ==========================================================================
   #/auth is the one screen every single user meets, and it was the plainest in
   the product: a white card in the middle of a pastel wash with two thirds of
   a 1360px viewport empty either side of it, saying nothing about what
   Skillprint is. The front door got an identity; the door people use did not.

   It is a split now — the product on ink, the form beside it — and the
   treatment is the front door's, deliberately. Two different looks on two
   screens a visitor meets seconds apart read as two different products.

   ON THE GROUND, which is the decision the rest follows from. The expressive
   ramp says of itself that it is for "shapes on a dark ground, where
   saturation is free and contrast is not a question a reader's eyes have to
   answer". This is that ground.

   THE SECOND ATTEMPT, and why the first was not enough. The first version put
   the product's SVG fingerprint on ink beside the form. It was honest and it
   was dull: one static figure with a slow rotation, on the screen whose entire
   job is to convince a stranger to finish a form. What that space is for is a
   showreel of the product, with a progress bar per scene — so it is a canvas
   now, three scenes, drawn in js/views/authstage.js, and both halves of the
   screen are dark.

   ON THE PALETTE. Every value in the rule below is measured, and
   lens-palette.test.js reads these exact declarations out of this file and
   checks them, so a future hand cannot lighten one for looks and quietly put
   text under AA. --clay-500, which colours every error message in the product,
   measures 4.72:1 on this ground — a pass, by 0.22. That is the same margin
   --ink-500 was living on when axe's own rounding rejected it and the token had
   to move, so the dark side carries its own warmer clay at 9.20:1 instead of a
   colour that only clears the bar on one browser's arithmetic.

   ON THE MOTION, which is what was asked for and what the rules constrain.
   Everything that moves is drawn on the canvas or is a bar. Not one letter is
   animated continuously, and no letter is painted into the bitmap at all — a
   glyph mid-animation reports a colour the stylesheet does not contain, which
   produced 91 phantom axe violations once and cost a day, and a glyph inside a
   canvas is invisible to a screen reader entirely.

   `:has()` rather than a modifier class: the forgot-password and reset screens
   render the same `.auth` shell with a card and no stage, and they should stay
   the centred card on paper that they are.
   ========================================================================== */
/* The chrome above has to come down with the screen. This is the only route
   that takes the whole viewport to ink, and a cream top bar over it reads as a
   rendering fault — on a 390px phone it was the first thing in the frame, and
   the page ground showed again as a pale strip under the form. */
body:has(.authstage) { background: #070d0c; }
body:has(.authstage) .topbar {
  background: rgba(7, 13, 12, .82);
  border-block-end-color: rgba(255, 255, 255, .10);
}
body:has(.authstage) .brand { color: #f2f7f5; }        /* 18.10:1 */
body:has(.authstage) .topbar__actions .btn--ghost { color: #a9c1bb; }  /* 10.29:1 */
body:has(.authstage) .topbar__actions .btn--ghost:hover {
  background: rgba(255, 255, 255, .08);
  color: #f2f7f5;
}

.auth:has(.authstage) {
  /* Measured, not chosen. The ratios are against --auth-ink. */
  --auth-ink: #070d0c;     /* the ground both halves share */
  --auth-lift: #0e1817;    /* a field, lifted just off it */
  --auth-edge: #556e69;    /* a control's boundary — 3.57:1, non-text needs 3.0 */
  --auth-text: #f2f7f5;    /* 18.10:1 */
  --auth-muted: #a9c1bb;   /* 10.29:1 */
  --auth-dim: #93aca6;     /* 8.11:1 — hints and placeholders, which are text too */
  --auth-warn: #e8a184;    /* 9.20:1 — still clay, still means "something is wrong" */

  display: grid;
  grid-template-columns: minmax(0, 1.08fr) minmax(0, 1fr);
  align-items: stretch;
  gap: 0;
  min-block-size: calc(100vh - 54px);
  padding-block: 0;
  place-items: stretch;
  background: var(--auth-ink);
  color: var(--auth-text);
}

/* --- the stage ------------------------------------------------------------ */
.authstage {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  background: var(--auth-ink);
  color: var(--auth-text);
  display: grid;
  align-content: end;
  padding: clamp(1.4rem, 3.2vw, 2.75rem);
  min-block-size: 21rem;
}

.authstage__canvas {
  position: absolute;
  inset: 0;
  z-index: -2;
  display: block;
  inline-size: 100%;
  block-size: 100%;
}

/* The scrim, which is the whole reason type over a moving picture is readable:
   the ground is darkened under the words and the picture is vignetted into the
   panel edge so it fades rather than stopping at a hard line. The measured
   ratios above assume this — at the bottom of the panel the veil is 94% opaque
   over the canvas, so the words sit on --auth-ink and not on whatever colour a
   spoke happens to be passing through. */
.authstage__veil {
  position: absolute;
  inset: 0;
  z-index: -1;
  background:
    linear-gradient(to top,
      rgba(7, 13, 12, .95) 0%,
      rgba(7, 13, 12, .93) 22%,
      rgba(7, 13, 12, .58) 42%,
      rgba(7, 13, 12, 0) 66%),
    radial-gradient(125% 85% at 50% 38%,
      rgba(7, 13, 12, 0) 38%, rgba(7, 13, 12, .55) 82%, rgba(7, 13, 12, .9) 100%);
}

.authstage__inner {
  position: relative;
  display: grid;
  gap: clamp(1rem, 2.2vw, 1.7rem);
  inline-size: min(34rem, 100%);
}

.authstage__badge {
  margin: 0 0 .55rem;
  color: var(--auth-dim);
  font-size: .74rem;
  font-weight: 600;
  letter-spacing: .12em;
  text-transform: uppercase;
}

/* The one thing on this screen allowed to be loud. Arabic has no case, so the
   uppercase is scoped to ltr and the weight carries the emphasis in both. */
.authstage__title {
  margin: 0 0 .6rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.9rem, 3.9vw, 3.15rem);
  line-height: 1.04;
  text-wrap: balance;
  color: var(--auth-text);
}
[dir="ltr"] .authstage__title { text-transform: uppercase; letter-spacing: -.018em; }

.authstage__sub {
  margin: 0;
  max-inline-size: 34ch;
  color: var(--auth-muted);
  font-size: clamp(.92rem, 1.1vw, 1.02rem);
  line-height: 1.55;
}

/* --- the rail: one progress bar per scene, as in the reference ------------ */
.authstage__rail {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: .9rem;
  align-items: start;
}

.authstage__tab {
  font: inherit;
  display: grid;
  gap: .5rem;
  padding: 0;
  border: 0;
  background: none;
  text-align: start;
  cursor: pointer;
  color: var(--auth-dim);
  min-block-size: 44px;
  align-content: start;
}
.authstage__tab:hover { color: var(--auth-muted); }
.authstage__tab.is-live { color: var(--auth-text); }
.authstage__tab:focus-visible {
  outline: 2px solid var(--fp-1);
  outline-offset: 4px;
  border-radius: 4px;
}

.authstage__tabname {
  font-size: .82rem;
  font-weight: 600;
  letter-spacing: .01em;
}

.authstage__bar {
  display: block;
  block-size: 3px;
  border-radius: 2px;
  background: rgba(255, 255, 255, .18);
  overflow: hidden;
}
.authstage__fill {
  display: block;
  block-size: 100%;
  inline-size: 100%;
  background: var(--fp-1);
  transform: scaleX(0);
  /* A number, not a keyword: this file has no `left` in it, and 0/100% flip
     correctly under the [dir="rtl"] rule below. */
  transform-origin: 0 50%;
}
[dir="rtl"] .authstage__fill { transform-origin: 100% 50%; }

/* The bar fills over exactly the dwell the carousel uses, because authstage.js
   sets --dwell from the same DWELL constant it schedules on. A bar that
   finishes before or after the scene changes is a bar that is lying about what
   it measures. */
@media (prefers-reduced-motion: no-preference) {
  .authstage__tab.is-live .authstage__fill {
    animation: authFill var(--dwell, 7000ms) linear forwards;
  }
}
@keyframes authFill {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}
/* Held under the pointer or a focused tab. The clock stops in authstage.js at
   the same moment, and both restart together when the pointer leaves. */
.authstage.is-held .authstage__fill { animation-play-state: paused; }
/* Motion off: the carousel does not advance, so the bar states which scene is
   showing rather than how long is left. */
.authstage.is-still .authstage__tab.is-live .authstage__fill { transform: scaleX(1); }

/* --- the form side -------------------------------------------------------- */
.auth__formside {
  display: grid;
  place-items: center;
  padding: clamp(1.5rem, 4vw, 3rem) 1.15rem;
  background: var(--auth-ink);
}

/* No card. On this ground a panel at 1.08:1 against the page is a border
   drawn around nothing — the reference screen does not have one either, and
   the form reads better sitting directly on the ink. */
.auth:has(.authstage) .auth__card {
  inline-size: min(25rem, 100%);
  background: none;
  border: 0;
  box-shadow: none;
  padding: 0;
}

.auth:has(.authstage) h1 { color: var(--auth-text); }
.auth:has(.authstage) .auth__lede { color: var(--auth-muted); }
.auth:has(.authstage) .label { color: var(--auth-muted); }
.auth:has(.authstage) .hint { color: var(--auth-dim); }
.auth:has(.authstage) .error-text { color: var(--auth-warn); }

.auth:has(.authstage) .input {
  background: var(--auth-lift);
  border-color: var(--auth-edge);
  color: var(--auth-text);
}
.auth:has(.authstage) .input::placeholder { color: var(--auth-dim); }
.auth:has(.authstage) .input:focus {
  border-color: var(--fp-1);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--fp-1) 22%, transparent);
}

/* The primary action. This is the ONE place in the product where a colour from
   the expressive ramp carries text — the note beside those tokens says they
   never do, and this is the exception, at 11.69:1 for #04140f on --fp-1, which
   is measured in lens-palette.test.js rather than asserted here. */
.auth:has(.authstage) .btn--primary {
  background: var(--fp-1);
  color: #04140f;
  font-weight: 600;
}
.auth:has(.authstage) .btn--primary:hover:not(:disabled) {
  background: color-mix(in srgb, var(--fp-1) 86%, #fff);
}
.auth:has(.authstage) .btn--secondary {
  background: var(--auth-lift);
  border-color: var(--auth-edge);
  color: var(--auth-text);
}

.auth:has(.authstage) .tabs { background: rgba(255, 255, 255, .06); }
.auth:has(.authstage) .tabs button { color: var(--auth-muted); }
.auth:has(.authstage) .tabs button[aria-selected="true"] {
  background: var(--auth-lift);
  color: var(--auth-text);
  box-shadow: none;
}

.auth:has(.authstage) .rolepick label {
  border-color: var(--auth-edge);
  background: var(--auth-lift);
  color: var(--auth-text);
}
.auth:has(.authstage) .auth__aside a { color: var(--fp-1); }

/* --- the arrival ---------------------------------------------------------- */
/* Finite, and on blocks rather than on running text: the form rises once, and
   the words rise again each time the scene changes. Under reduced motion
   nothing runs and everything is drawn in place. */
@media (prefers-reduced-motion: no-preference) {
  .auth__formside { animation: authRise .6s cubic-bezier(.22, 1, .36, 1) .08s backwards; }
  .authstage__copy.is-in { animation: authRise .55s cubic-bezier(.22, 1, .36, 1) backwards; }
  .authstage__rail { animation: authRise .6s cubic-bezier(.22, 1, .36, 1) .18s backwards; }
}
@keyframes authRise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* --- narrow ---------------------------------------------------------------
   Below 900px the split becomes a stack and the stage becomes a band above the
   form rather than disappearing: a phone visitor should still be told what
   this is.

   Tight, and the tightness is the point. An earlier band ran 290px before the
   form began, which put the email field below the fold on a 740px phone — a
   returning user had to scroll before they could type. A sign-in screen that
   makes signing in the second thing you can do has been decorated at the
   user's expense. The subline goes, the badge goes, and the title and the rail
   stay: those two say what the product is and let a visitor page through it. */
@media (max-width: 900px) {
  .auth:has(.authstage) {
    grid-template-columns: minmax(0, 1fr);
    min-block-size: 0;
  }
  /* 190px, measured. At the 120px the content collapsed to, the scene sized
     itself off the shorter side and drew a 72px figure in a band the width of
     a phone — a picture too small to be a picture, with the headline sitting
     on top of it. This is the smallest band the figure still reads in, and it
     leaves the email field at y=472 in a 740px viewport, well above the fold. */
  .authstage {
    min-block-size: 190px;
    padding: 1.1rem 1.15rem 1.25rem;
  }
  /* Heavier here than on a wide screen, and it has to be: on the desktop panel
     the picture has 500px of its own above the words, and on a phone band the
     type sits directly on top of the figure. Without this the headline reads
     over a moving fingerprint rather than over a ground. */
  .authstage__veil {
    background:
      linear-gradient(to top,
        rgba(7, 13, 12, .93) 0%, rgba(7, 13, 12, .78) 55%, rgba(7, 13, 12, .5) 100%),
      radial-gradient(120% 100% at 50% 40%, rgba(7, 13, 12, 0) 30%, rgba(7, 13, 12, .6) 100%);
  }
  .authstage__inner { gap: .85rem; }
  .authstage__badge { display: none; }
  .authstage__sub { display: none; }
  .authstage__title { font-size: 1.5rem; margin-block-end: 0; }
  .authstage__rail { gap: .6rem; }
  .authstage__tabname { font-size: .72rem; }
  .auth__formside { padding-block-start: 1.35rem; }
}
