/*
 * GENERATED FILE. Do not edit this copy.
 *
 * Written by gen-docs-lib.js from main-site/assets/css/theme.css.
 * Change that file and run:  node gen-docs-lib.js
 *
 * It exists because Vercel builds each project from its own root directory, so
 * this site cannot import the portal's modules. 5h: duplicate them, and keep
 * the two copies identical.
 *
 * Identical, and it is the whole reason this generator learned to write a
 * CSS banner. 16d: "two axis theming exactly as the main site, same tokens,
 * same data-color-theme and data-mode attributes, light default, WCAG AA in
 * every combination." Same tokens means the same file.
 *
 * The docs site draws its own components out of these, and part 3 of phase 12
 * is the standing warning about that: a ratio cannot tell you a state is drawn
 * only in hue, and every component here is new.
 *
 * Nothing differs from the portal's copy but this banner.
 */

/* Careers@GFTV theme system.
 *
 * Implements gftv-theme.md exactly. Two independent axes on <html>:
 * data-color-theme (classic, hello) and data-mode (light, dark). Both
 * attributes are always present, set by the pre-paint script in every <head>,
 * so every colour block can select on both without a fallback.
 *
 * Rules that are not negotiable, from that file:
 *   - No gradients, orbs, or blobs. Flat tints and glass surfaces only.
 *   - Never hardcode a colour in component CSS. Reference a token.
 *   - Light mode is the default. prefers-color-scheme is not read on load.
 *   - --brand-dark is a fill, never body sized text. Accent text is
 *     --brand-text.
 *   - --text-light never carries meaning on a nested control. Use
 *     --text-muted-strong on --surface-active.
 *   - Everything that opens, closes, or switches state animates, 150 to 220ms,
 *     and honours prefers-reduced-motion.
 *
 * One documented departure from gftv-theme.md: the font is Proxima Nova, the
 * GFTV branding font, in place of Inter. --font is still the single point of
 * control and no component sets font-family of its own, so the rule that
 * mattered is unchanged.
 */

/* ---------------------------------------------------------------------------
 * Font
 *
 * Proxima Nova is licensed and is not on Google Fonts, so it is self hosted
 * instead of pulled from a CDN. That is also what the offline requirement in
 * section 14 needs: a font from a third party host cannot be precached by the
 * service worker and would leave installed copies of the site unstyled.
 *
 * Only the regular weight has been supplied so far, so it is the only face
 * declared. Weights 500, 600, and 700 are used by headings, buttons, and the
 * brand, and the browser synthesises those from the 400 face until the real
 * files arrive. Faux bold is a fair approximation and is much better than
 * declaring a face that 404s.
 *
 * When the other weights are supplied, drop them into
 * main-site/assets/fonts/ under the names in the commented blocks below and
 * uncomment each one. Nothing else needs to change.
 * ------------------------------------------------------------------------ */

@font-face {
  font-family: 'Proxima Nova';
  src: url('/assets/fonts/ProximaNova-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/*
@font-face {
  font-family: 'Proxima Nova';
  src: url('/assets/fonts/ProximaNova-Medium.woff2') format('woff2');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Proxima Nova';
  src: url('/assets/fonts/ProximaNova-Semibold.woff2') format('woff2');
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Proxima Nova';
  src: url('/assets/fonts/ProximaNova-Bold.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
*/

/* ---------------------------------------------------------------------------
 * Structural tokens. No colour, no axis.
 * ------------------------------------------------------------------------ */

:root {
  --glass-blur: 16px;
  --radius: 14px;
  --radius-sm: 8px;

  /* One duration for everything that opens, closes, or moves a reader from one
   * view to another: a modal, the mobile drawer, the filter panel, a tab, the
   * theme switch, and a navigation between two pages of the same area.
   *
   * They were four different durations before, between 0.12s and 0.25s, which
   * is why a modal and a theme change felt like two different products. This is
   * the single value they now share.
   *
   * **220ms, not the theme switch's 250ms**, which is the one thing here
   * worth reading twice. The theme switch is what somebody pointed at when they
   * asked for everything to feel the same, and it was itself the outlier: the
   * non-negotiable rule in gftv-theme.md is 150 to 220ms, and the body's
   * background transition had sat at 250ms since phase 1 in breach of it.
   * Matching everything *to* 250ms would have spread that breach across the
   * whole site. So the token is the top of the permitted range, the theme
   * switch now uses it like everything else, and the 30ms it loses is not a
   * difference anybody can see.
   *
   * What deliberately does *not* use it: hovers, focus rings, and the two fill
   * toggles on the star and the bookmark. Those are feedback on a thing the
   * pointer is already touching, where this duration reads as lag, not
   * as polish. They stay at 0.15s to 0.18s, which is inside the same rule. The
   * star pair was at 0.12s, under the 150ms floor, and was corrected while this
   * was being done.
   *
   * The reduced motion block near the foot of this file overrides every
   * duration to near zero, so this token is never the last word. */
  --transition: 0.22s;
  --ease: ease;

  /* Proxima Nova carries no CJK glyphs, so every Han character falls past the
     Latin families, none of which have them either, and lands on the reader's
     own system default: PingFang SC on Apple platforms, Microsoft YaHei on
     Windows, Noto Sans CJK SC on Android and Linux.

     No CJK face is named here on purpose. Naming them buys nothing on any
     current platform, since the system defaults are already the right faces,
     and it overrides a reader who has deliberately chosen a different Chinese
     font in their own settings.

     What actually makes this work is the lang attribute, not the stack. Han
     characters are shared between Chinese and Japanese, so a browser with no
     language to go on may render Chinese text in a Japanese face, where a
     number of shared characters are drawn differently. Every page sets
     lang="zh-Hans-SG" from the pre-paint script, which is what tells the
     browser to pick a Simplified Chinese face. If that attribute ever stops
     being set, the Chinese will quietly render with the wrong glyph forms.

     Latin text is unaffected: Proxima Nova is still first, and a font stack is
     resolved per character, not per element. */
  --font: 'Proxima Nova', -apple-system, BlinkMacSystemFont, 'Segoe UI',
    Roboto, Helvetica, Arial, sans-serif;
}

/* Held blank until the dictionary has been applied, so a reader of a non
   default language does not watch English repaint into their language on every
   page load. Set by the pre-paint script, cleared by i18n.js, and cleared by a
   timeout in that same script if anything goes wrong.

   visibility over display, so layout is already computed when the page
   appears and nothing jumps. The attribute is never set for the default
   language, and never set at all without JavaScript, so neither path can leave
   a reader looking at nothing. */
:root[data-i18n-pending='true'] body {
  visibility: hidden;
}

/* Han script sits on a slightly different optical baseline and reads tight at
   the line height Latin copy wants, so the Chinese document gets a little more
   room. Scoped to the locale attribute the pre-paint script sets, so it costs
   English nothing. */
:root[data-locale='zh'] body {
  line-height: 1.75;
  letter-spacing: 0.01em;
}

/* The hidden attribute always wins.
 *
 * A browser hides [hidden] with display: none from its own stylesheet, and any
 * author rule that sets display beats it, including an ordinary one like
 * `.field { display: flex }`. So `el.hidden = true` quietly does nothing, but
 * only when the element happens to carry a class with a display rule on it,
 * which is what makes it so hard to spot: the same line works everywhere else.
 *
 * This stylesheet had already fallen into it three times and patched it three
 * times per component, in .phase-notice, .nav-backdrop, and .feature-explainer.
 * It fell in again on the translation report form, where submitting a report
 * left the form on screen under its own thank you. Those three rules are now
 * redundant and are phase 12's to remove.
 *
 * !important is right here and not heavy handed: hidden is a statement
 * about whether the element exists for the reader at all, and nothing about how
 * a component lays out should be able to overrule it.
 *
 * **Do not "simplify" this away on the theory that the UA stylesheet already
 * marks it important.** It does not in every engine, and it was removed once on
 * exactly that theory. The probe that appeared to confirm it was run against a
 * deployment that still had this rule in it, so it was measuring this rule. If
 * you want to check, remove it locally and read a hidden .field in devtools. */
[hidden] {
  display: none !important;
}

/* ---------------------------------------------------------------------------
 * classic, light
 * ------------------------------------------------------------------------ */

:root[data-color-theme='classic'][data-mode='light'] {
  --brand: #ffffff;
  --brand-dark: #4a6a8a;
  --brand-text: #1a3a5a;
  --brand-on: #ffffff;
  --bg: #ffffff;
  --bg-alt: #f5f5f5;
  --surface: rgba(255, 255, 255, 0.6);
  --surface-hover: rgba(245, 245, 245, 0.8);
  --surface-active: rgba(235, 235, 235, 0.95);
  --border: rgba(180, 190, 200, 0.4);
  --border-strong: rgba(140, 155, 170, 0.6);
  --text: #1a1a2e;
  --text-muted: #4a5568;
  --text-light: #656d7a;
  --shadow: rgba(100, 120, 140, 0.12);
  --glass-highlight: rgba(255, 255, 255, 0.6);
  --link: #c9202f;
  --link-visited: #4b4bd6;
  --danger: #9e2c20;
  --danger-hover: #8a2a2a;
  --danger-on: #ffffff;
  --ok: #146030;
  --warn: #8a5200;
}

/* ---------------------------------------------------------------------------
 * hello, light
 * ------------------------------------------------------------------------ */

:root[data-color-theme='hello'][data-mode='light'] {
  --brand: #fedc00;
  --brand-dark: #806d00;
  --brand-text: #3a3000;
  --brand-on: #ffffff;
  --bg: #fffde0;
  --bg-alt: #fff9c4;
  --surface: rgba(254, 220, 0, 0.35);
  --surface-hover: rgba(254, 220, 0, 0.55);
  --surface-active: rgba(254, 220, 0, 0.7);
  --border: rgba(200, 170, 0, 0.3);
  --border-strong: rgba(160, 130, 0, 0.5);
  --text: #2e2800;
  --text-muted: #5a4e00;
  --text-light: #7a6c00;
  --shadow: rgba(160, 130, 0, 0.15);
  --glass-highlight: rgba(255, 255, 255, 0.5);
  --link: #c9202f;
  --link-visited: #4b4bd6;
  --danger: #9e2c20;
  --danger-hover: #8a2a2a;
  --danger-on: #ffffff;
  --ok: #146030;
  --warn: #8a5200;
}

/* ---------------------------------------------------------------------------
 * classic, dark
 * ------------------------------------------------------------------------ */

:root[data-color-theme='classic'][data-mode='dark'] {
  --brand: #ffffff;
  --brand-dark: #8fb0cf;
  --brand-text: #cfe0f0;
  --brand-on: #10161d;
  --bg: #0f1317;
  --bg-alt: #161b21;
  --surface: rgba(255, 255, 255, 0.06);
  --surface-hover: rgba(255, 255, 255, 0.1);
  --surface-active: rgba(255, 255, 255, 0.14);
  --border: rgba(160, 180, 200, 0.18);
  --border-strong: rgba(160, 180, 200, 0.32);
  --text: #eceff3;
  --text-muted: #a7b2be;
  --text-light: #8a95a3;
  --shadow: rgba(0, 0, 0, 0.5);
  --glass-highlight: rgba(255, 255, 255, 0.08);
  --link: #ff6b74;
  --link-visited: #a9a9ff;
  --danger: #ff8a80;
  --danger-hover: #ffb3ac;
  --danger-on: #1a0f0e;
  --ok: #6ee7a0;
  --warn: #fbbf24;
}

/* ---------------------------------------------------------------------------
 * hello, dark
 * ------------------------------------------------------------------------ */

:root[data-color-theme='hello'][data-mode='dark'] {
  --brand: #fedc00;
  --brand-dark: #fedc00;
  --brand-text: #ffe873;
  --brand-on: #2e2800;
  --bg: #14120a;
  --bg-alt: #1c1a0e;
  --surface: rgba(254, 220, 0, 0.1);
  --surface-hover: rgba(254, 220, 0, 0.16);
  --surface-active: rgba(254, 220, 0, 0.22);
  --border: rgba(254, 220, 0, 0.2);
  --border-strong: rgba(254, 220, 0, 0.36);
  --text: #f5f0dc;
  --text-muted: #c4ba8e;
  --text-light: #9a9270;
  --shadow: rgba(0, 0, 0, 0.55);
  --glass-highlight: rgba(255, 255, 255, 0.06);
  --link: #ff6b74;
  --link-visited: #a9a9ff;
  --danger: #ff8a80;
  --danger-hover: #ffb3ac;
  --danger-on: #1a0f0e;
  --ok: #6ee7a0;
  --warn: #fbbf24;
}

/* ---------------------------------------------------------------------------
 * Derived tokens. These resolve against whichever block above is active, so
 * they stay correct in all four combinations without being restated.
 * ------------------------------------------------------------------------ */

:root {
  /* Hover step above --brand-dark, both modes. */
  --brand-dark-hover: color-mix(in srgb, var(--brand-dark) 85%, var(--text));
  /* Secondary text on --surface-active, where --text-light fails AA in dark. */
  --text-muted-strong: color-mix(in srgb, var(--text) 70%, transparent);
  /* Callout tints, 14% of the status colour. */
  --callout-danger-bg: color-mix(in srgb, var(--danger) 14%, transparent);
  --callout-ok-bg: color-mix(in srgb, var(--ok) 14%, transparent);
  --callout-warn-bg: color-mix(in srgb, var(--warn) 14%, transparent);
  /* Focus indicator. --border is under 3:1 by design, --brand-dark is not. */
  --focus-ring: var(--brand-dark);
}

/* --border-control: the boundary of a control, which is the one thing 1.4.11
 * requires 3:1 of and the one thing this build had no token for. Phase 12
 * part 3.
 *
 * **There are now three border tokens and the difference between them is what
 * they are drawn around**, not how heavy they look:
 *
 *   --border         a separator between things that are not controls. Faint on
 *                    purpose, 1.21:1 to 1.72:1, and 1.4.11 does not reach it.
 *   --border-strong  emphasis. A hover, a selected state, a table header rule,
 *                    a badge, a chart crosshair. Measured at 1.70:1 to 2.76:1
 *                    and left exactly where it was — see below.
 *   --border-control the edge of a control, where a reader has to be able to
 *                    find the control at all. 3:1 in all four combinations.
 *
 * **The first shape of this raised --border-strong instead, and that was the
 * wrong instrument.** Twenty two rules reference it and only one of them, the
 * switch track, is a control's boundary; the rest are .job-card:hover,
 * .chip.active, .bucket-tab[aria-selected], five badges, the annotation quote
 * bar and the chart crosshair. Raising the shared token would have re-weighted
 * the main browsing surface's hover state to fix a switch. **A hover is not a
 * component boundary** — the card is already bounded when nothing is hovering
 * it — so none of those twenty one had failed anything.
 *
 * **Derived rather than four literals.** A per-theme value is one somebody has
 * to re-measure every time a --bg moves; a mix of --text against transparent
 * composites over whatever is behind it and stays correct in all four by
 * construction. It keeps each theme's character without being told to, because
 * --text is blue-black in classic and brown-black in hello.
 *
 * **Two percentages, because one cannot serve both modes.** The mix that clears
 * 3:1 against a white page is a near-white line against a near-black one, which
 * is over the line and looks it. 55 and 42 land between 3.3:1 and 3.6:1
 * everywhere, which is the band this was aiming at. */
:root[data-mode='light'] {
  --border-control: color-mix(in srgb, var(--text) 55%, transparent);
}

:root[data-mode='dark'] {
  --border-control: color-mix(in srgb, var(--text) 42%, transparent);
}

/* --control-off-bg: the unchecked half of a two state control. **Phase 12
 * part 6, and it is a hierarchy fix rather than a contrast one.** The switch
 * track used --surface-active, which in hello light is the brand yellow at 70%
 * and read louder than the 14% green tint a *checked* switch gets: the state
 * hierarchy backwards, in the theme most people see it in. Both states clear
 * 1.4.11 comfortably, so no measurement reported it — part 3's visual pass is
 * what found it, which is what that pass exists for.
 *
 * **Achromatic on purpose, and this is the one place a literal beats a
 * derivation.** --border-control mixes --text because it wants each theme's
 * character; this wants the opposite. Off is the absence of a state and has no
 * colour to be, and letting colour alone mean "on" is what makes the pair
 * readable at a glance rather than by comparing two tints. A mix of --text
 * would carry hello's brown and classic's blue into it, which is a hue on the
 * state that means "no". So: neutral by construction, two values because a
 * dark surface needs light on it and a light one needs dark. */
:root[data-mode='light'] {
  --control-off-bg: rgba(0, 0, 0, 0.1);
}

:root[data-mode='dark'] {
  --control-off-bg: rgba(255, 255, 255, 0.14);
}

/* ---------------------------------------------------------------------------
 * Chart series, 8.4
 *
 * Three categorical hues in a fixed order: views, apply clicks, answered yes.
 * Fixed means a series keeps its colour whatever else is on the chart, which is
 * the one rule that makes two charts on this page comparable at a glance.
 *
 * **Keyed on the mode and not on the colour theme**, which is deliberate: a
 * reader who switches from classic to hello is changing the furniture, not the
 * meaning of the lines, and a chart whose series changed colour with the site's
 * accent would be unreadable across a language of its own. Both sets were
 * checked against all four surfaces this site actually paints, the white and
 * the pale yellow in light mode and the two near blacks in dark.
 *
 * **The dark steps are chosen, not derived.** They are not the light hues
 * lightened: an automatic flip lands outside the lightness band that keeps a
 * thin 2px line legible on a dark surface, and it was the mid greens that gave
 * way first. Both sets pass the same six checks, including deuteranopia and
 * protanopia separation, which is why the greens are teal-leaning and the
 * second series is an orange instead of the red that would read as an error.
 *
 * Not the status tokens. --ok, --warn, and --danger mean something on this
 * site, and a series that borrowed one would be claiming a posting is broken
 * because it happens to be third in a legend.
 * ------------------------------------------------------------------------ */

:root[data-mode='light'] {
  --chart-1: #2563c9;
  --chart-2: #c2410c;
  --chart-3: #0d8a6a;
}

:root[data-mode='dark'] {
  --chart-1: #5688dd;
  --chart-2: #c9772f;
  --chart-3: #27a877;
}

/* ---------------------------------------------------------------------------
 * Base
 * ------------------------------------------------------------------------ */

*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: var(--font);
}

html {
  /* iOS does not zoom on focus when inputs are at least 16px, and inheriting
     from here keeps that true without a font-size on every input. */
  font-size: 16px;
  -webkit-text-size-adjust: 100%;
}

html,
body {
  min-height: 100%;
}

body {
  background: var(--bg);
  color: var(--text);
  line-height: 1.6;
  transition:
    background-color var(--transition) var(--ease),
    color var(--transition) var(--ease);
  /* Nothing may scroll the page sideways at any width down to 320px. */
  overflow-x: hidden;
}

/* Links carry no underline, in any state.
 *
 * This overrides the mitigation gftv-theme.md attached to the --link contrast
 * exception, which was "keep the underline on in-content links, since the
 * underline is the affordance rather than the colour". Without it, a link
 * inside a paragraph is told apart from body text by colour alone. Measured
 * against --text, that is 3.03:1 in classic light, 2.63:1 in hello light,
 * 2.39:1 in classic dark and 2.42:1 in hello dark; visited links are worse
 * again, between 1.86:1 and 2.66:1. Only one of the eight clears the 3:1 WCAG
 * 1.4.1 asks of a colour that is the sole distinction.
 *
 * **The light two used to be the comfortable pair and phase 12 part 3 changed
 * that, deliberately.** --link was #ef3340 and measured 4.24:1 and 3.68:1
 * against --text — but only 4.02:1 and 3.50:1 against the page it is *read*
 * on, and 1.4.3 wants 4.5:1 there for text at this size. Darkening it to
 * #c9202f fixed the pair that decides whether the words can be made out at all
 * and cost the pair that decides whether they look like a link, because moving
 * a link colour towards the text is the only direction that improves the first.
 *
 * That trade is only available because the distinction was never resting on
 * colour: the weight step below is what carries it, it was already carrying the
 * two dark combinations at 2.39:1 and 2.42:1, and all four now lean on the same
 * thing rather than two of them leaning on it and two getting away with colour.
 * The states are listed out so that no component rule and no user agent default
 * can reintroduce an underline.
 */
a,
a:link,
a:visited,
a:hover,
a:focus,
a:active {
  text-decoration: none;
}

:link {
  color: var(--link);
}

:visited {
  color: var(--link-visited);
}

/* A link inside body copy is heavier than the text around it.
 *
 * This is the non-colour distinction WCAG 1.4.1 asks for, and it is what
 * replaces the underline. It works in all four theme combinations, including
 * the two dark ones where colour alone reaches only 2.39:1 and 2.42:1 against
 * --text, and it applies to visited links too, which are worse again.
 *
 * Only prose containers are targeted. Navigation, footer lists, buttons, and
 * chips already read as controls and set their own weight.
 *
 * :where() keeps the specificity at (0,0,1), so any component rule that names
 * a class wins without needing !important or a longer selector.
 *
 * Note while only ProximaNova-Regular is present: the browser synthesises this
 * weight from the 400 face. The difference is still clearly visible, and it
 * becomes a real cut once the Semibold file is supplied.
 */
:where(p, li, dd, dt, td, th, blockquote, figcaption, .lede) a {
  font-weight: 600;
}

/* The footer link lists are navigation, not prose, even though they are
   marked up as lists. */
.site-footer ul a {
  font-weight: 400;
}

button {
  cursor: pointer;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

img,
svg,
video {
  max-width: 100%;
  height: auto;
}

svg {
  /* Icons are inline SVG coloured with currentColor. Never emoji. */
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
  flex: none;
}

/* Long words, uuids, and tag names wrap instead of pushing the layout. */
h1,
h2,
h3,
h4,
p,
li,
td,
th,
dd,
dt {
  overflow-wrap: anywhere;
}

h1,
h2,
h3,
h4 {
  line-height: 1.25;
  font-weight: 600;
}

:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--text) 45%, transparent);
}

::selection {
  background: color-mix(in srgb, var(--brand-dark) 35%, transparent);
  color: var(--text);
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* ---------------------------------------------------------------------------
 * Loading
 *
 * Three pieces, because loading is not one situation.
 *
 *   .spinner    an action is in flight and the result has no shape yet
 *   .skeleton   content is coming whose shape is already known
 *   .delayed    wraps either, and shows nothing for the first 250ms
 *
 * The delay is the part worth keeping. A spinner that appears for 80ms and
 * vanishes reads as a flicker, and most same origin fetches finish inside
 * that. Showing nothing until 250ms means a fast response never flashes, and
 * a slow one is still acknowledged well before anyone wonders.
 *
 * No gradients anywhere here. gftv-theme.md forbids them, which rules out the
 * usual shimmer sweep across a skeleton, so these use a flat tint and an
 * opacity pulse instead. It reads calmer and it obeys the rule.
 *
 * Every one of these carries text for a screen reader. An animation on its own
 * announces nothing.
 * ------------------------------------------------------------------------ */

@keyframes gftv-spin {
  to {
    transform: rotate(360deg);
  }
}

@keyframes gftv-pulse {
  50% {
    opacity: 0.45;
  }
}

@keyframes gftv-fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

.spinner {
  --spinner-size: 1.25rem;
  --spinner-weight: 2px;
  display: inline-block;
  flex: none;
  width: var(--spinner-size);
  height: var(--spinner-size);
  border-radius: 50%;
  border: var(--spinner-weight) solid
    color-mix(in srgb, var(--text) 16%, transparent);
  border-top-color: var(--brand-dark);
  animation: gftv-spin 700ms linear infinite;
}

.spinner.small {
  --spinner-size: 1rem;
  --spinner-weight: 2px;
}

.spinner.large {
  --spinner-size: 2.25rem;
  --spinner-weight: 3px;
}

/* On a --brand-dark fill, where the accent border would vanish. */
.btn-primary .spinner {
  border-color: color-mix(in srgb, var(--brand-on) 30%, transparent);
  border-top-color: var(--brand-on);
}

.skeleton {
  background: var(--surface-active);
  border-radius: var(--radius-sm);
  animation: gftv-pulse 1.4s ease-in-out infinite;
}

.skeleton-line {
  height: 0.75rem;
  margin: 0.5rem 0;
}

.skeleton-line.title {
  height: 1.125rem;
  width: 40%;
}

.skeleton-line.short {
  width: 30%;
}

.skeleton-line.long {
  width: 85%;
}

/* Nothing for 250ms, then a fade. `both` holds opacity 0 through the delay. */
.delayed {
  animation: gftv-fade-in 180ms ease 250ms both;
}

.loading-row {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  color: var(--text-muted);
}

/* The page is held hidden while a non default language loads its dictionary.
   This is the one thing allowed to show through, so that hold is not a blank
   screen. visibility is not blocked by an ancestor: a descendant may set
   visible and be seen. */
.boot-loader {
  display: none;
}

:root[data-i18n-pending='true'] .boot-loader {
  visibility: visible;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  position: fixed;
  inset: 0;
  z-index: 300;
  background: var(--bg);
  color: var(--text-muted);
}

@media (prefers-reduced-motion: reduce) {
  /* One deliberate exception to the blanket rule further down, which stops
     every animation. A loading indicator that does not move indicates nothing,
     so these slow down and do not stop. Both are small, localised, and
     constant, which is not what the reduced motion guidance is aimed at: that
     targets large sweeps, parallax, and anything that moves the page under a
     reader. */
  .spinner {
    animation-duration: 2s !important;
    animation-iteration-count: infinite !important;
  }

  .skeleton {
    animation-duration: 2.4s !important;
    animation-iteration-count: infinite !important;
  }

  .delayed {
    animation: none !important;
  }
}

/* ---------------------------------------------------------------------------
 * Primitives
 * ------------------------------------------------------------------------ */

.glass-card {
  background: var(--surface);
  backdrop-filter: blur(var(--glass-blur)) saturate(150%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(150%);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow) 0 8px 24px, inset 0 1px 0 var(--glass-highlight);
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  /* Touch targets are at least 44 by 44 CSS pixels, per section 3. */
  min-height: 44px;
  padding: 0.625rem 1.125rem;
  border-radius: var(--radius-sm);
  font-weight: 600;
  font-size: 1rem;
  text-decoration: none;
  border: 1px solid transparent;
  transition: background-color 0.18s ease, border-color 0.18s ease,
    color 0.18s ease;
}

a.btn,
a.btn:visited {
  text-decoration: none;
}

.btn-primary,
a.btn-primary,
a.btn-primary:visited {
  background: var(--brand-dark);
  color: var(--brand-on);
}

.btn-primary:hover {
  background: var(--brand-dark-hover);
}

.btn-secondary,
a.btn-secondary,
a.btn-secondary:visited {
  background: var(--surface-active);
  color: var(--text);
  border-color: var(--border);
}

.btn-secondary:hover {
  background: var(--surface-hover);
}

.btn-danger {
  background: var(--danger);
  color: var(--danger-on);
}

.btn-danger:hover {
  background: var(--danger-hover);
}

.btn-quiet {
  background: none;
  color: var(--text-muted);
  border-color: transparent;
}

.btn-quiet:hover {
  background: var(--surface-hover);
  color: var(--text);
}

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  transition: background-color 0.18s ease, color 0.18s ease;
}

.icon-btn:hover {
  background: var(--surface-hover);
  color: var(--text);
}

.icon-btn.small {
  width: 32px;
  height: 32px;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  min-height: 32px;
  padding: 0.25rem 0.75rem;
  border-radius: 999px;
  background: var(--surface-active);
  border: 1px solid var(--border);
  color: var(--text-muted-strong);
  font-size: 0.875rem;
  font-weight: 500;
  text-decoration: none;
}

a.chip,
a.chip:visited {
  color: var(--text-muted-strong);
  text-decoration: none;
}

.callout {
  padding: 0.875rem 1rem;
  border-radius: var(--radius-sm);
  border: 1px solid currentColor;
  font-size: 0.9375rem;
}

/* The note tone is the only one of the four whose border is not its own text
   colour, so it is the only one measuring under 3:1 — 1.21:1 to 1.72:1 across
   the four combinations.

   **Measured, and deliberately left.** Settled 30 August 2026, phase 12 part 3.
   1.4.11 asks 3:1 of what identifies a user interface component or its state,
   and a callout is a block of static prose: it is not operable, it has no
   states, and nothing about it has to be found before it can be used. The
   --surface-active fill already sets it apart from the page, and the words
   inside carry the whole of the meaning — the border is trim. Raising it would
   have put a visibly bolder box on nineteen files including the home page,
   /search, the job page, /login, /register and /status, to fix something that
   had not failed.

   `tests/phase12-test.mjs --only=contrast` measures this border and prints it
   as advisory rather than skipping it, so the number stays visible and the
   exemption stays a decision somebody made rather than a gap. ok, warn and
   danger are asserted, and pass. */
.callout.note {
  background: var(--surface-active);
  border-color: var(--border);
  color: var(--text-muted-strong);
}

.callout.ok {
  background: var(--callout-ok-bg);
  color: var(--ok);
}

.callout.warn {
  background: var(--callout-warn-bg);
  color: var(--warn);
}

.callout.danger {
  background: var(--callout-danger-bg);
  color: var(--danger);
}

/* ---------------------------------------------------------------------------
 * Theme modal, per gftv-theme.md
 * ------------------------------------------------------------------------ */

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: color-mix(in srgb, #000 55%, transparent);
}

/* **The same class on two shapes, deliberately.** Everything dialog.js builds
   is a native <dialog> since phase 12 part 6; the two modals that are created
   and destroyed per use — danger-confirm.js and recovery-codes.js — are still
   plain divs with this class, and both want the same full bleed dim. What the
   dialog element needs on top of it is the UA's own dialog styling taken back
   off: a <dialog> is fit-content in both axes with a solid border and a margin
   of auto, none of which a backdrop wants. */
dialog.modal-backdrop {
  width: auto;
  height: auto;
  max-width: none;
  max-height: none;
  margin: 0;
  border: 0;
  color: inherit;
  opacity: 1;
  transition:
    opacity var(--transition) var(--ease),
    display var(--transition) var(--ease) allow-discrete,
    overlay var(--transition) var(--ease) allow-discrete;
}

/* **An author's `display: flex` beats the UA's `dialog:not([open])
   { display: none }`**, so without this a closed dialog sits over the page.
   It is also the closed half of the transition: `allow-discrete` above is what
   lets a display change animate rather than cut, and `@starting-style` below
   is the opening half. **Between them they are the first time a modal on this
   site has actually faded.** `.hidden`, which this replaces, is
   `display: none !important` in the utilities at the bottom of this file, and
   it has beaten the opacity and transform transitions written here since phase
   1: the stylesheet said 220ms and every modal in the build appeared and
   vanished instantly. */
dialog.modal-backdrop:not([open]) {
  display: none;
  opacity: 0;
}

/* The dim is painted by the element itself, the way it is for the two modals
   that are still divs, so the native pseudo-element must not paint a second
   one over it. */
dialog.modal-backdrop::backdrop {
  background: transparent;
}

@starting-style {
  dialog.modal-backdrop[open] {
    opacity: 0;
  }
}

.modal {
  width: min(24rem, 100%);
  max-height: calc(100dvh - 2rem);
  overflow-y: auto;
  padding: 1.25rem;
  background: var(--bg-alt);
  transform: translateY(0);
  transition: transform var(--transition) var(--ease);
}

dialog.modal-backdrop:not([open]) .modal {
  transform: translateY(0.75rem);
}

@starting-style {
  dialog.modal-backdrop[open] .modal {
    transform: translateY(0.75rem);
  }
}

@media (prefers-reduced-motion: reduce) {
  dialog.modal-backdrop,
  dialog.modal-backdrop .modal {
    transition: none;
  }
}

.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1rem;
}

.modal-head h2 {
  font-size: 1.125rem;
}

/* The line adminMessage and accountMessage write into when a modal is open,
   built by dialog.js directly under the heading. It is the page's message bar
   moved inside, because the page is inert and behind the dim while a dialog is
   showing and a "give this a title first" written out there is a sentence
   nobody in the modal can read. The margin matches .modal-head's, so a panel
   with a message keeps the spacing of one without. */
.modal-message {
  margin-bottom: 1rem;
}

.modal-section-label {
  margin: 1rem 0 0.5rem;
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.modal-section-label:first-of-type {
  margin-top: 0;
}

.mode-toggle {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.5rem;
}

/* **--border-control and not --border**, phase 14 part 1. The token above says
   what it is for in as many words -- "the edge of a control, where a reader has
   to be able to find the control at all. 3:1 in all four combinations" -- and
   these three controls were still on the faint separator token when phase 12
   part 3 created it. That pass measured the switch track, the language pills
   and the stars; the two modals were not in its fixture, so nothing reported
   them and the miss travelled to a second site.

   Measured at 1.23:1 to 1.70:1 on --border, against 3:1. The unselected state
   of all three is a translucent --surface fill with a hairline round it, so the
   line is the whole of what says a control is there.
   tests/phase14-test.mjs --only=contrast is what says so now, in all four. */
.mode-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  min-height: 44px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-control);
  background: var(--surface);
  color: var(--text);
  font-weight: 500;
  transition: background-color 0.18s ease, color 0.18s ease;
}

.mode-btn.active {
  background: var(--brand-dark);
  color: var(--brand-on);
  border-color: transparent;
}

/* The time based option, per gftv-theme.md. Light and dark stay side by side
   and the third spans the row beneath them: three equal columns put the longer
   label in a box too narrow to read at 320px, and the full width row also says
   visually that this one is a different kind of choice, a rule for picking a
   mode, not a mode itself.

   Lives here instead of in app.css because it is part of the shared theme
   system now, and theme.css is what implements that. */
.mode-btn-wide {
  grid-column: 1 / -1;
}

.mode-note {
  margin-top: 0.5rem;
  font-size: 0.875rem;
  color: var(--text-muted);
}

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

/* --border-control, for the reason .mode-btn above carries. */
.swatch {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 44px;
  padding: 0.5rem 0.75rem;
  border-radius: var(--radius-sm);
  background: var(--surface);
  border: 1px solid var(--border-control);
  color: var(--text);
  font-weight: 500;
}

/* The classic swatch is #ffffff and would vanish on a light modal, so the
   active state carries a containment ring as well as a border. */
.swatch.active {
  border-color: var(--swatch-color);
  box-shadow: 0 0 0 2px var(--swatch-color),
    0 0 0 3px color-mix(in srgb, var(--text) 50%, transparent);
}

.swatch-dot {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--swatch-color);
  border: 1px solid color-mix(in srgb, var(--text) 30%, transparent);
}

/* Language picker. Same modal shell as the theme picker, one row per language,
   each naming itself in its own script. */
.locale-list {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.locale-btn {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  min-height: 48px;
  padding: 0.5rem 0.875rem;
  border-radius: var(--radius-sm);
  background: var(--surface);
  /* --border-control, for the reason .mode-btn carries. */
  border: 1px solid var(--border-control);
  color: var(--text);
  font-size: 1rem;
  font-weight: 500;
  text-align: left;
}

.locale-btn:hover {
  background: var(--surface-hover);
}

.locale-btn.active {
  background: var(--brand-dark);
  color: var(--brand-on);
  border-color: transparent;
}

/* The tick marks the active language. Hidden, not removed, so the rows
   do not change width when the choice moves and the list does not jump. */
.locale-check {
  visibility: hidden;
}

.locale-btn.active .locale-check {
  visibility: visible;
}

.locale-note {
  margin-top: 0.875rem;
  color: var(--text-muted);
  font-size: 0.875rem;
}

/* ---------------------------------------------------------------------------
 * Utilities
 * ------------------------------------------------------------------------ */

.hidden {
  display: none !important;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  left: 0.5rem;
  top: -3rem;
  z-index: 200;
  padding: 0.625rem 1rem;
  border-radius: var(--radius-sm);
  background: var(--brand-dark);
  color: var(--brand-on);
  text-decoration: none;
  transition: top 0.18s ease;
}

.skip-link:focus {
  top: 0.5rem;
}
