/*
 * Facette shared visual system.
 *
 * This file is the persistent, data-site-shell stylesheet loaded on every
 * public page. The custom internal-navigation script (site-navigation.js)
 * never swaps or removes anything marked data-site-shell, so the tokens,
 * nav, footer, buttons, and typography roles defined here stay mounted and
 * unchanged across a page transition. Nothing page-specific belongs here;
 * page <style> blocks own layout that is genuinely different per page.
 *
 * Nav height is a fixed 72px at every breakpoint on purpose: an earlier
 * version tried to shrink it under 700px in page-level CSS, but this file
 * always rendered it at 72px anyway, so mobile content padding quietly
 * disagreed with the real nav height. Keeping one number everywhere removes
 * that mismatch instead of re-adding it responsively.
 */

:root{
  --bg:#070b0d;
  --panel:#0d1518;
  --panel-2:#101b1f;
  --ink:#eef7f6;
  --muted:#92a5a7;
  --quiet:#627578;
  --cyan:#12e6ee;
  --cyan-soft:rgba(18,230,238,.10);
  --amber:#f2c46d;
  --amber-soft:rgba(242,196,109,.10);
  --amber-line:rgba(242,196,109,.34);
  --line:rgba(157,205,204,.14);
  --line-hi:rgba(157,225,221,.28);
  --display:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
  --mono:"SFMono-Regular",Consolas,"Liberation Mono",monospace;
  --max:1240px;
  --nav-h:72px;
  --wordmark-e-color:var(--cyan);
}

*{box-sizing:border-box}
html{overflow-y:scroll;scrollbar-gutter:stable;scroll-behavior:smooth;scroll-padding-top:calc(var(--nav-h) + 1px);background:var(--bg);color-scheme:dark}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--display);line-height:1.55;-webkit-font-smoothing:antialiased}
body main{padding-top:var(--nav-h)}
a{color:inherit;text-decoration:none}
button,input,select,textarea{font:inherit}
img{max-width:100%}
h1,h2,h3,p{margin-top:0}

.shell{width:min(calc(100% - 40px),var(--max));margin:auto}
@media(min-width:1000px){
  :root{--max:1380px}
  .shell{width:min(calc(100% - 96px),var(--max))}
}
@media(max-width:700px){
  /* nav/footer keep the 40px gutter down to every viewport per
     docs/shell-geometry.md — only general page-content shells tighten to
     32px here. Without :not(), this silently narrowed .nav-inner and
     .footer-inner too on any page that doesn't also carry the inline
     data-site-critical .nav .nav-inner override (which wins on
     specificity where it exists), making nav width depend on which page
     you loaded. See AGENTS.md, "The recurring failure mode". */
  .shell:not(.nav-inner):not(.footer-inner){width:min(calc(100% - 32px),var(--max))}
}

/* ---------- Navigation ---------- */

.nav{
  position:fixed;
  inset:0 0 auto 0;
  z-index:50;
  /* Outer box must render 73px (border-box, border included) to match
     docs/shell-geometry.md and every page's data-site-critical inline
     copy. --nav-h (72px) alone under the sitewide *{box-sizing:border-box}
     reset swallows this element's own 1px border into the declared
     height, rendering 72px instead of 73 and producing a real ~1px CLS
     between first paint (critical CSS says 73px) and this stylesheet
     taking over (previously 72px). +1px restores the documented number
     without changing --nav-h, which nav-inner and main padding still
     correctly use at 72px. */
  height:calc(var(--nav-h) + 1px);
  border-bottom:1px solid rgba(157,205,204,.08);
  background:rgba(7,11,13,.86);
  backdrop-filter:blur(18px);
  -webkit-backdrop-filter:blur(18px);
  isolation:isolate;
}
.nav .nav-inner{
  height:var(--nav-h);
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:24px;
}
/* Canon (founder decision, FAC-SITE-BRAND-V9-003 revision 2): the NAV and the
   FOOTER now use the SAME lockup — the approved transparent CYAN v9 micro F
   followed by live "ACETTE". The earlier rule that kept the footer as
   full live "FACETTE" text with no image is retired, not a live option to
   silently restore.

   One construction, one asset, one hierarchy: cyan F, white letters, and the
   fourth letter of FACETTE (F-A-C-[E]-T-T-E) in cyan via .wordmark-e and
   --wordmark-e-color. That glyph is the third character of the live "ACETTE"
   run in both places. So cyan appears twice in each lockup, deliberately, and
   the F's cyan is in its own pixels rather than applied by CSS.

   The asset is assets/brand/facette-f-micro-cyan.png in both. The WHITE micro
   derivative it replaced is retired: the two are the same 223x426 canvas with
   the same 183x326 ink box and differ only in color, which makes them exactly
   the pair an edit swaps by accident, so
   scripts/check-brand-composition.js forbids the white filename and samples
   this image's decoded pixels.

   The chamfered black app-icon tile is a STANDALONE icon surface only —
   favicon, apple-touch, PWA. It must never appear behind or beside an inline
   nav or footer wordmark; the supplied package says so too, and the check
   fails if any tiled icon filename is referenced from an inline lockup.

   Both <a>s carry aria-label="Facette", so the accessible name is the exact
   word "Facette" whether or not the image loads: an alt on the image would
   concatenate into "F ACETTE", and a broken image with no label would leave
   the link named only "ACETTE". Both images are alt="" for the same reason.
   Do not recolor either F with a filter. */
.brand-mark{display:inline-flex;align-items:center;gap:0.33px;flex:0 0 auto;min-width:0;color:var(--ink);text-decoration:none}
/* 23px is measured, not guessed. Only 326 of the F's 426 intrinsic rows
   are ink (the rest is symmetric transparent padding), so a 23px box paints
   17.6px of F against the 11.0px cap height of the 15px wordmark beside it:
   a 1.60 ratio, against the 1.6 that v9's brand-tokens.json fixes as
   primary_horizontal_f_to_acette_visual_height_ratio. It is also half the
   48px ceiling the brand authority sets for this simplified derivative, so
   the faceting never reaches the size where it smears. Nav and footer use
   the same 23px so the two lockups are the same object, not a pair of
   lookalikes.

   The 0.33px gap on .brand-mark above is measured from the F's VISIBLE ink
   edge, not from its canvas, and that distinction is the whole point: the
   canvas carries 20px of transparent padding per side, about 1.2px at this
   render size, so the visible gap is roughly flex-gap + 1.2px. 0.33px paints
   1.5px of real space at 360 through 1440 and 1.0px at 2560, inside the
   1.0-1.5px the founder requires (FAC-SITE-BRAND-V9-003 revision 2, tightened
   from the retired 2.0-3.0px band). Do NOT restore the retired 1.5px /
   2.7px, 1.25px / 2.4px, or 4px / 5.20px spacings.

   Why 0.33 and why the two numbers differ. Above 1380px .nav-inner centres in
   the rail and lands .brand-mark on a half-pixel x (582.5px at 2560), where
   Chromium genuinely rasterises the gap about 0.5px tighter. That is a real
   rendering difference, not a measurement artifact: it survives ink
   thresholds of 40, 96, 128 and 160 unchanged. The required window is 0.5px
   wide and that spread is 0.5px, so the ONLY values that put all ten required
   widths inside it are the ones that read exactly 1.5 and 1.0, and measurement
   shows a plateau across roughly 0.28-0.38px of flex gap. 0.33px is the centre
   of that plateau, chosen so drift in the CSS value cannot move the reading.
   There is no interior solution; re-measure from the ink edge at dsf 10 across
   all ten widths, do not re-guess, if this is ever retuned.

   width:auto plus the intrinsic width/height attributes reserve the box
   before the file arrives, so neither lockup reflows. Every page's
   data-site-critical block repeats this height as a bare .brand-f rule — NOT
   scoped to .nav — because a load that never receives this stylesheet would
   otherwise render both Fs at their intrinsic 223x426 and burst the nav and
   the footer alike. */
.brand-f{display:block;flex:0 0 auto;height:23px;width:auto;object-fit:contain}
.wordmark{
  font:700 15px/1 var(--display);
  letter-spacing:.16em;
  color:var(--ink);
  white-space:nowrap;
}
.wordmark-e{color:var(--wordmark-e-color,var(--cyan))}
.nav-links{
  display:flex;
  align-items:center;
  gap:25px;
  color:#a9b9ba;
  font:600 10px/1 var(--mono);
  letter-spacing:.12em;
  text-transform:uppercase;
}
.nav-links a{color:inherit}
.nav-links a:hover,
.nav-links a:focus-visible{color:var(--ink)}
.nav-links a.nav-cta{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  height:40px;
  padding:0 16px;
  border:1px solid var(--cyan);
  border-radius:8px;
  background:var(--cyan);
  color:#041110;
  font-family:var(--display);
  font-size:13px;
  font-weight:700;
  letter-spacing:0;
  text-transform:none;
  box-shadow:0 14px 40px rgba(18,230,238,.14);
}
.nav-links a.nav-cta:hover,
.nav-links a.nav-cta:focus-visible{
  background:#7cf7fa;
  border-color:#7cf7fa;
  color:#041110;
}

/* Mobile navigation menu: below 900px the inline text links (Faces / How
   it works / FAQ / Engineering) disappear from .nav-links same as before,
   but instead of vanishing with no replacement, .nav-toggle exposes them
   in .nav-menu, a disclosure panel anchored under the nav. Both elements
   sit outside .nav-links in the DOM (siblings of it inside .nav), so
   assets/site-navigation.js's SPA swap — which only replaces .nav-links'
   content on internal navigation — never touches them; they persist as
   the same nodes across every page transition, which is also why their
   event listeners are bound once in site-navigation.js rather than
   re-bound per page. */
.nav-toggle{display:none;align-items:center;justify-content:center;width:40px;height:40px;border:1px solid var(--line-hi);border-radius:8px;background:transparent;color:var(--ink);flex:0 0 auto}
.nav-toggle-bars,.nav-toggle-bars:before,.nav-toggle-bars:after{display:block;width:16px;height:2px;border-radius:1px;background:currentColor}
.nav-toggle-bars{position:relative}
.nav-toggle-bars:before,.nav-toggle-bars:after{content:"";position:absolute;left:0}
.nav-toggle-bars:before{top:-5px}
.nav-toggle-bars:after{top:5px}
.nav-menu{display:none}

@media(max-width:900px){
  .nav-links a:not(.nav-cta){display:none}
  /* .nav-inner has three flex children at this width now (brand-mark,
     .nav-links, .nav-toggle), not two — a plain `justify-content:
     space-between` (set above, unconditionally) would spread all three
     evenly and strand the CTA in the middle of the bar, away from the
     toggle beside it. An auto margin on .nav-links consumes the free
     space the container would otherwise have distributed, which pushes
     .nav-links and .nav-toggle to sit flush together at the right edge
     instead, without needing to touch justify-content or restructure the
     markup (.nav-toggle has to stay a direct sibling of .nav-links, not
     nested inside it, so site-navigation.js's SPA swap — which replaces
     only .nav-links itself — never tears down its listeners). */
  .nav-links{margin-left:auto}
  .nav-toggle{display:inline-flex}
  .nav-menu{
    position:fixed;
    left:0;
    right:0;
    top:calc(var(--nav-h) + 1px);
    z-index:49;
    display:flex;
    flex-direction:column;
    padding:10px 20px calc(16px + env(safe-area-inset-bottom));
    background:#0a1114;
    border-bottom:1px solid rgba(157,205,204,.14);
    box-shadow:0 24px 48px rgba(0,0,0,.4);
  }
  .nav-menu[hidden]{display:none}
  .nav-menu a{
    padding:14px 2px;
    border-bottom:1px solid var(--line);
    color:var(--ink);
    font:600 12px var(--mono);
    letter-spacing:.1em;
    text-transform:uppercase;
  }
  .nav-menu a:last-child{border-bottom:0}
  .nav-menu a.nav-cta{
    margin-top:10px;
    padding:0 16px;
    height:48px;
    display:inline-flex;
    align-items:center;
    justify-content:center;
    border:1px solid var(--cyan);
    border-radius:8px;
    background:var(--cyan);
    color:#041110;
    font-family:var(--display);
    font-size:14px;
    letter-spacing:0;
    text-transform:none;
  }
}
@media(max-width:700px){
  .nav-links{gap:0}
  .nav-links a.nav-cta{padding:0 14px;font-size:11px}
}

/*
 * At 600px and below the inline CTA leaves the bar (founder ruling,
 * FAC-SITE-BRAND-V9-003). A phone header carrying the lockup, a full
 * "Request early access" pill and the menu button reads as crowded, and the
 * pill is the one element that is genuinely redundant there: the same link,
 * same text and same destination is already inside the disclosed .nav-menu
 * and again in the homepage hero, so nothing becomes unreachable.
 *
 * This hides ONLY .nav-cta inside the inline .nav-links row. The lockup and
 * .nav-toggle stay visible and operable, #nav-menu's copy of the CTA is
 * untouched, and 601px and up keeps the frozen inline behaviour exactly.
 *
 * :not(.alpha-download) is load-bearing, not defensive tidiness. The whole
 * justification above is "the same action is one tap away in the disclosed
 * menu" — and that is false on /alpha/**, which carries NO .nav-toggle and
 * NO #nav-menu at all, and whose .nav-cta is the "Download Facette" link to
 * the release route rather than Request early access. Hiding it there would
 * strand testers on phones with no way to reach the download. The exclusion
 * keeps the rule pointed at exactly the public CTA the ruling names.
 */
@media(max-width:600px){
  .nav-links a.nav-cta:not(.alpha-download){display:none}
}

@media print{
  .nav,.nav-menu{display:none}
}

/* ---------- Footer ---------- */

.site-footer{
  padding:30px 0 calc(30px + env(safe-area-inset-bottom));
  border-top:1px solid rgba(157,205,204,.10);
  background:var(--bg);
  color:#6f8587;
}
/*
 * The footer is two deliberate GROUPS stacked in one column, not a row of
 * unrelated children (FAC-SITE-BRAND-V9-002, tightened by -003):
 *
 *   .footer-top     .footer-brand (wordmark + tagline) and .footer-links
 *   .footer-meta    copyright, full width, under a divider
 *
 * There is no banner group. The locked v9 banner is an authority asset in
 * ethanmonlux/facette and is deliberately NOT a runtime asset of this site
 * (founder ruling, FAC-SITE-BRAND-V9-003). Do not add a banner wrapper, an
 * image, a placeholder, or any decorative panel back into this footer.
 * scripts/check-brand-composition.js fails on the class names and the file
 * name alike, which is also why this note spells out neither.
 *
 * The previous rule was `flex-wrap` + `justify-content:space-between` over
 * four unrelated children, which distributed whatever slack the viewport
 * happened to leave into arbitrary gaps between a wordmark, a tagline, a
 * link list and a copyright line, and the <=700px rule then stacked all
 * four individually into a long loose column. Grouping first and laying
 * out the groups is what gives the composition a hierarchy at every width;
 * do not flatten these back into direct children of .footer-inner.
 *
 * Every selector here is scoped under a footer-owned class so none of it
 * can reach page content, .eyebrow, buttons, or form status text. That is
 * why the paragraph rules are `.footer-brand>p` / `.footer-meta>p` rather
 * than a container-descendant `p` rule: see AGENTS.md, "The recurring
 * failure mode".
 */
.footer-inner{
  display:grid;
  gap:10px;
}
.footer-inner .brand-mark{color:var(--ink)}
.footer-inner .wordmark{font-size:15px}

.footer-top{
  display:grid;
  grid-template-columns:minmax(0,1fr);
  gap:10px 48px;
  align-items:start;
}
.footer-brand{
  display:grid;
  justify-items:start;
  gap:4px;
}
.footer-brand>p{
  margin:0;
  max-width:34ch;
  color:#6f8587;
  font:600 10px/1.45 var(--mono);
  /* .12em read as gappy at phone widths against a wrapped two-line tagline;
     .06em is the same face and the same words, set tighter. */
  letter-spacing:.06em;
  text-transform:uppercase;
}

/*
 * Two columns at phone widths, three from 860px, and the row count is the
 * whole ballgame for mobile height (FAC-SITE-BRAND-V9-003).
 *
 * Each row is 44px because that is the touch-target floor, so four rows cost
 * 176px and three cost 132px. The founder's 20-25% reduction target is simply
 * unreachable with four rows: 4x44 plus the brand block, the meta row and
 * minimal gaps floors at roughly 260px against a 247px ceiling. Three rows is
 * what the 14px column gap and the .05em mobile tracking were measured for,
 * and both stay. Font size is unchanged at 10px and the targets are unchanged
 * at 44px; only spacing ever moved.
 *
 * HISTORICAL, and no longer a live rule (FAC-393): the link set used to carry
 * a sixth "Support Facette on Ko-fi" label, wide enough that it spanned both
 * tracks at 383px and below and pushed the phone layout to four rows there.
 * That measurement (156.0px label against tracks of 145.5px at 360, 153.0px at
 * 375, 155.5px at 380, 157.5px at 384) justified a `.footer-link-wide`
 * span rule that is gone with the link. The five remaining labels all fit one
 * track at every width this site is checked at, so nothing spans and the phone
 * layout is three rows everywhere. If a wide label is ever added back, measure
 * its own threshold rather than reusing 383.
 */
.footer-links{
  display:grid;
  grid-template-columns:repeat(2,minmax(0,1fr));
  gap:0 14px;
  font:600 10px/1 var(--mono);
  letter-spacing:.05em;
  text-transform:uppercase;
}
.footer-links a{
  display:inline-flex;
  align-items:center;
  min-height:44px;
  color:inherit;
}
.footer-links a:hover,
.footer-links a:focus-visible{color:var(--ink)}

.footer-meta{
  border-top:1px solid var(--line);
  padding-top:12px;
  display:grid;
  gap:10px;
  justify-items:start;
}
/*
 * The copyright keeps the uppercase mono label face. The non-affiliation
 * notice beside it does not, so this rule carries :not(.footer-notice)
 * rather than being overridden afterwards: a container rule at (0,1,1)
 * outranks a bare `.footer-notice` at (0,1,0) whatever the source order,
 * and the fix for that is always the exclusion at the source. See
 * AGENTS.md, "The recurring failure mode".
 */
.footer-meta>p:not(.footer-notice){
  margin:0;
  color:#6f8587;
  font:600 10px/1.5 var(--mono);
  letter-spacing:.12em;
  text-transform:uppercase;
}

/*
 * Non-affiliation notice (FAC-393). Sentence case at 11.5px, because it is a
 * sentence a person has to read and 10px uppercase mono at .12em is a label
 * face that punishes anything longer than a few words.
 *
 * It lives INSIDE .footer-meta, as a second paragraph beside the copyright,
 * rather than as a third group under .footer-inner. The footer has exactly
 * two groups by ruling (FAC-SITE-BRAND-V9-003) and
 * scripts/check-brand-composition.js enforces both that and the absence of
 * any content below the copyright row; a legal notice is not a reason to
 * loosen the guard that keeps the removed banner out.
 */
.footer-notice{
  margin:0;
  max-width:78ch;
  color:#6f8587;
  font-size:11.5px;
  line-height:1.6;
}

/*
 * Desktop keeps its frozen proportions. The tightening above is aimed at the
 * phone layout, where the founder asked for 20-25% less height; desktop was
 * already balanced and the ruling is explicit that it must not be loosened
 * OR enlarged, so the values it had are restored here rather than inherited.
 */
@media(min-width:860px){
  .footer-inner{gap:22px}
  .footer-top{grid-template-columns:minmax(0,1fr) auto;gap:18px 48px}
  .footer-brand{gap:9px}
  .footer-brand>p{line-height:1.6}
  .footer-links{
    grid-template-columns:repeat(3,minmax(0,auto));
    justify-items:start;
    gap:2px 28px;
    letter-spacing:.08em;
  }
  .footer-links a{min-height:30px}
  .footer-meta{padding-top:15px}
}

/* ---------- Buttons ---------- */

.button{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  min-height:48px;
  padding:0 20px;
  border:1px solid transparent;
  border-radius:8px;
  font-weight:700;
  font-size:13px;
  cursor:pointer;
  transition:transform .18s ease,filter .18s ease;
}
.button:hover{transform:translateY(-1px);filter:brightness(1.06)}
.button.primary{
  background:var(--cyan);
  color:#041110;
  box-shadow:0 14px 40px rgba(18,230,238,.16);
}
.button.secondary{
  border-color:var(--line-hi);
  background:rgba(18,230,238,.035);
  color:var(--ink);
}

@media(prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  .button{transition:none}
  .button:hover{transform:none}
}

/* ---------- Typography roles ---------- */

.eyebrow{
  display:block;
  margin:0 0 18px;
  color:var(--cyan);
  font:600 10px var(--mono);
  letter-spacing:.15em;
  text-transform:uppercase;
}
.eyebrow:before{
  content:"";
  display:inline-block;
  width:22px;
  height:1px;
  margin-right:11px;
  vertical-align:middle;
  background:currentColor;
}
.back-link{
  display:inline-flex;
  gap:8px;
  align-items:center;
  color:var(--cyan);
  font:700 11px var(--mono);
  letter-spacing:.12em;
  text-transform:uppercase;
  margin-bottom:24px;
}
.back-link:hover,
.back-link:focus-visible{color:var(--ink)}

/* Technical index links: a set of related pages/sections, styled as a
   monospace reference list rather than a row of pill buttons. */
.jump-links{
  display:flex;
  flex-wrap:wrap;
  gap:1px;
  margin-top:26px;
  background:var(--line);
  border:1px solid var(--line);
  border-radius:10px;
  overflow:hidden;
}
.jump-links a{
  flex:1 1 160px;
  padding:11px 14px;
  background:#090f11;
  color:var(--cyan);
  font:600 10px var(--mono);
  letter-spacing:.08em;
  text-transform:uppercase;
}
.jump-links a:hover,
.jump-links a:focus-visible{background:rgba(18,230,238,.07);color:var(--ink)}

/* Definition/spec rows: a compact key-style list for short technical facts.
 *
 * THE SHARED ROW RULE (indexed rows and label/value rows alike). Every row
 * component on this site that puts a short marker column beside a body
 * column follows the same three invariants, so an index number never
 * floats high on one row and low on the next:
 *
 *   1. two columns: a fixed-width marker column, then minmax(0,1fr);
 *   2. `align-items:center` on the row, so the marker and the body are
 *      centered against each other regardless of how many lines the body
 *      wraps to and regardless of the marker's much smaller font-size;
 *   3. the marker stays a LEFT-side index/label, never centered inside the
 *      row (`justify-self:start`) — its column edge is the alignment
 *      reference all rows in the list share.
 *
 * Without (2) the default `align-items:stretch` makes each grid item fill
 * the row height and paint its text at the TOP of that box: a 10px mono
 * "01" then sits visibly above the 13px/1.55 body line beside it, and the
 * gap grows with every extra line the body wraps to, which is exactly the
 * "number floating high, low, or inconsistently across rows" defect.
 *
 * Page-owned rows of the same shape (`.compat-row` in how-it-works,
 * `.roadmap-row` in engineering) restate these three declarations rather
 * than inventing per-page nudges; `scripts/check-section-alignment.js`
 * asserts the resulting geometry on every public page. The markup for
 * those two lives on exactly one page each, so their rules stay on that
 * page (nothing page-specific belongs in this file, see the header) —
 * what is shared is the rule shape above, enforced by that check.
 */
.spec-list{
  display:grid;
  gap:1px;
  background:var(--line);
  border:1px solid var(--line);
  border-radius:10px;
  overflow:hidden;
}
.spec-row{
  display:grid;
  grid-template-columns:26px minmax(0,1fr);
  align-items:center;
  gap:12px;
  padding:14px 16px;
  background:#090f11;
  color:#b7c5c6;
  font-size:13px;
  line-height:1.5;
}
.spec-row>span{justify-self:start;color:var(--cyan);font:600 10px/1.5 var(--mono);letter-spacing:.08em}

/* Caveat / status callouts. Amber variant is reserved for warnings and
   limitations; the default is informational and stays on-brand cyan. */
.callout{
  padding:18px 20px;
  border:1px solid var(--line-hi);
  border-radius:13px;
  background:var(--cyan-soft);
  color:var(--muted);
  font-size:14px;
}
.callout strong{color:var(--cyan)}
.callout.warn{background:var(--amber-soft);border-color:var(--amber-line)}
.callout.warn strong{color:var(--amber)}

/* ---------- Game attribution ---------- */

/*
 * The per-game unofficial-project notice (FAC-393), used wherever a page
 * presents a supported game's imagery or compatibility. Shared here rather
 * than restated per page because the same words appear on index.html,
 * faces/index.html, how-it-works/ and faq.html, and three page-owned copies
 * of one component is exactly the drift AGENTS.md's shared-component rule
 * exists to prevent.
 *
 * Quiet on purpose: it is a disclosure, not a feature, and it must not
 * compete with the capture it sits under. It is still real body text at 12.5px
 * rather than the 10px uppercase mono used for labels, because a disclaimer
 * nobody can comfortably read is not a disclosure.
 *
 * The link is underlined rather than only recolored: it points off-site to
 * the game's own official page, and that is worth looking like a link.
 */
.attribution{
  margin:14px 0 0;
  /* A measure, because this is the one place on the site where a small
     type size meets a full-width container: inside a FAQ answer at 1440px
     the column is over 1000px wide, and 12.5px set that long is a line a
     reader loses their place in. */
  max-width:70ch;
  /* --muted, not --quiet, and that is a legibility decision rather than a
     stylistic one. --quiet against --bg measures 4.09:1, under the 4.5:1
     floor for text this size, and it is used elsewhere for captions a reader
     can skip. This is a disclosure whose entire job is to be read, so the
     smaller size and the position under the media carry the "secondary"
     signal instead of the colour. */
  color:var(--muted);
  font-size:12.5px;
  line-height:1.6;
}
/* Brighter than the sentence around it, so the official-product link is
   distinguishable by more than an underline. */
.attribution a{
  color:var(--ink);
  text-decoration:underline;
  text-underline-offset:2px;
}
.attribution a:hover,
.attribution a:focus-visible{color:var(--cyan)}

/* ---------- Face media fallback ---------- */

/* Shared by every prominent Face <img> (index.html and faces/index.html):
   the element carrying this class is the img's positioned ancestor and
   already has its own aspect-ratio/height from page-level CSS, so hiding
   the img on error never collapses the box. img[hidden] prevents the
   browser's own broken-image icon from ever rendering; the fallback layer
   replaces it with a restrained, on-brand panel that names the Face and
   offers a retry instead of a permanent black box or a false success state. */
.face-media{position:relative}
/* No opacity transition here. An earlier revision carried
   `transition:opacity .2s ease` on this image, which nothing ever
   triggered (the load/error path toggles `hidden`, i.e. `display`, not
   opacity) but which would have turned any future opacity change into a
   200ms fade on top of media that is already the slowest thing on the
   page. Removed under the media-flicker pass: media should appear, not
   fade in. */
.face-media img[hidden]{display:none}
.face-media-fallback{
  position:absolute;
  inset:0;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:8px;
  padding:16px;
  text-align:center;
  background:var(--panel);
  color:var(--muted);
}
.face-media-fallback[hidden]{display:none}
.face-media-fallback strong{color:var(--ink);font-size:13px;font-weight:650;letter-spacing:-.01em}
.face-media-fallback span{color:var(--quiet);font:600 9px var(--mono);letter-spacing:.08em;text-transform:uppercase}
.face-media-retry{
  border:1px solid var(--line-hi);
  border-radius:7px;
  background:transparent;
  color:var(--cyan);
  padding:7px 14px;
  font:600 9px var(--mono);
  letter-spacing:.08em;
  text-transform:uppercase;
  cursor:pointer;
}
.face-media-retry:hover,
.face-media-retry:focus-visible{color:var(--ink);border-color:var(--cyan)}

/* ---------- Media placeholder (anti-flicker) ---------- */

/*
 * Every large media box on this site reserves its geometry before load,
 * so nothing SHIFTS — but a reserved box still has to paint something in
 * the window between "layout is known" and "the image or poster is
 * decoded", and painting a flat fill there is what reads as a flicker on
 * reload: the text is already on screen (it came inline with the HTML)
 * while the media boxes blink dark and then fill in.
 *
 * Measured cause, on a server configured like the production one
 * (`cache-control: public, max-age=0, must-revalidate`, which is Vercel's
 * default for an unhashed static file): every image and poster must
 * REVALIDATE on reload before it can paint, one round trip each. Chromium
 * holds the previous paint for <img> across a reload, so the images
 * mostly survive it; a <video>'s poster does not, and the poster box was
 * measured painting flat #020404 for whole frames after reload while the
 * rest of the page was stable.
 *
 * The fix is a placeholder that costs no network at all: a ~20px JPEG of
 * the asset itself, inlined as a data URI in `--media-lqip` on the box.
 * It is present in the very first painted frame, so the box shows a dim
 * version of its own image instead of an empty fill, and the real asset
 * paints over it. Nothing fades, nothing moves, and there is no extra
 * request to be revalidated. Scoped to the boxes where a blank paint was
 * actually measured; do not scatter it onto media that never flickered.
 *
 * When an asset is replaced, regenerate its placeholder from the new file
 * (scale to 20px wide, quality ~12) or the box will briefly show a dim
 * version of the OLD image.
 *
 * `cover`, NOT `contain`, and that is a measured correction rather than a
 * preference (FAC-SITE-MEDIA-RELOAD-STABILITY-001). A ~20px JPEG cannot
 * carry its source's exact ratio: the encoder rounds both axes to whole
 * (and, for 4:2:0 chroma, even) pixels, so every placeholder in this repo
 * is a few per cent off the asset it stands for.
 *
 *   box                              box ratio  LQIP    LQIP ratio  gap
 *   .combined-frame / .clip-frame    1.185/1.190  20x16   1.250      -5.2% height
 *   .stage-shot / .face-frame        3.556        20x6    3.333      -6.25% width
 *
 * Under `contain` that gap became a visible border of unpainted
 * background around the placeholder: 14px of dark band above and below a
 * 542px combined capture, 40px of dark bar either side of a 1295px Face
 * strip. The real image, `object-fit:contain` inside a box declared at the
 * image's own ratio, fills the box exactly. So the two states did not
 * occupy the same rectangle, and every reload played a placeholder that
 * was 5-6% smaller than the picture replacing it: a size snap inside a box
 * whose own geometry never moved, which is precisely why the geometry
 * instruments (check-reload-shudder.js, CLS) reported it as stable.
 *
 * `cover` makes the placeholder fill the box the media fills. It crops the
 * placeholder instead, by at most 0.6px of a 20px source, which is not
 * resolvable at any render size. Do not "restore" `contain`: it is the
 * defect. scripts/check-media-flicker.js measures the painted placeholder
 * rectangle against the media rectangle and fails when they differ.
 *
 * The two ≤700px overrides (`.stage-shot.media-lqip`,
 * `.face-frame.media-lqip`, both `background-size:auto 100%`) still win on
 * specificity and are still correct: at that width the image is an 820px
 * strip the visitor pans across, not a contained fit, so the placeholder
 * is sized to the box height and pinned left to match it.
 */
.media-lqip{
  background-image:var(--media-lqip);
  background-size:cover;
  background-position:center;
  background-repeat:no-repeat;
}

/* ---------- Combined game-plus-Face capture ---------- */

/*
 * Shared by index.html's product-proof pair and faces/index.html's two
 * Face sections: one real capture showing the game on the main display
 * with its Face on a second display below it (2560 x 2160). Shown whole,
 * never cropped or stretched, with its geometry reserved from the
 * capture's own intrinsic ratio before a byte loads.
 *
 * WHAT THE PAGE ACTUALLY LOADS (FAC-SITE-TELEMETRY-FIRST-001 revision 4):
 * not the 3.7-4.6 MB source PNG. Each capture is a <picture> with WebP and
 * JPEG derivatives at 640 and 1280 CSS pixels (see
 * scripts/build-image-derivatives.js), chosen by srcset/sizes. The frame's
 * aspect-ratio below is declared from the capture's own 2560 x 2160 shape
 * and is what reserves the box before any of them load, so switching
 * candidates never moves layout.
 *
 * The capture is still a real <a href> pointing at the full-resolution
 * PNG, so it works with no JavaScript and keeps native mouse and keyboard
 * activation. It no longer opens a new tab: assets/site-face-viewer.js
 * intercepts the activation and shows the full-resolution file in the
 * shared <dialog> viewer instead, in "fit" mode (the whole near-square
 * capture at once) rather than the "strip" mode the ultrawide 2560 x 720
 * Faces use. That is also what keeps the original off the initial page
 * load: it is fetched when, and only when, a visitor opens the viewer.
 *
 * STRUCTURE MATTERS HERE: the link lives INSIDE .combined-frame and wraps
 * only non-interactive content (the image and its two overlay labels).
 * The .face-media-fallback panel, which carries a Retry BUTTON, is a
 * SIBLING of the link, not a descendant. An earlier revision wrapped the
 * whole frame — fallback included — in the anchor, which nests
 * interactive content inside a link: invalid HTML, and two focusable
 * actions in one link for keyboard and screen-reader users. The retry
 * handler's stopPropagation() in assets/site-face-viewer.js only masks
 * the pointer symptom; it cannot fix the semantics. Keep any new
 * interactive affordance outside the anchor.
 *
 * The focus ring is therefore drawn INSIDE the link's box
 * (outline-offset is negative): .combined-frame clips to its border-radius
 * with overflow:hidden, so an outward ring on a link inside it would be
 * clipped away and the keyboard focus state would silently disappear.
 *
 * Both pages set --proof-accent per game for the tag color. Since this is
 * shared, a page must NOT restate these selectors in its own <style>: an
 * identical selector there ties on specificity and swaps winners between
 * a direct load and an internal navigation (see AGENTS.md, "A second,
 * load-path-dependent variant of the same bug"). Page-specific sizing
 * uses a page-owned class with a `main ` ancestor instead, e.g.
 * faces/index.html's `main .faces-combined`. */
.combined-figure{margin:0}
.combined-frame{position:relative;aspect-ratio:2560/2160;overflow:hidden;border:1px solid #030505;border-radius:12px;background-color:#020404}
.combined-link{display:block;height:100%;cursor:zoom-in}
.combined-link:focus-visible{outline:2px solid var(--cyan);outline-offset:-3px}
/* <picture> is a phrasing wrapper with no box of its own by default, so
   the <img> inside it would not inherit the link's height the way a
   direct child did. Making it a block that fills the link keeps the
   image's contain-fit behaviour identical to before the responsive
   sources were introduced. */
.combined-frame picture{display:block;height:100%}
.combined-frame img{width:100%;height:100%;object-fit:contain;display:block}
.combined-tag{position:absolute;left:12px;top:12px;display:inline-flex;align-items:center;gap:7px;padding:7px 10px;border:1px solid rgba(255,255,255,.16);border-radius:6px;background:rgba(2,7,9,.82);color:var(--proof-accent,var(--cyan));font:600 9px var(--mono);letter-spacing:.09em;text-transform:uppercase}
.combined-tag:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
.combined-hint{position:absolute;right:10px;bottom:10px;padding:6px 9px;border:1px solid rgba(255,255,255,.18);border-radius:6px;background:rgba(2,7,9,.88);color:#dce9e8;font:600 8.5px var(--mono);letter-spacing:.07em;text-transform:uppercase;pointer-events:none}
.combined-link:hover .combined-hint{border-color:var(--cyan);color:var(--cyan)}
/* When the image could not load, the whole link goes away, not just its
   visual hint. The fallback panel covers the frame, but hiding only the
   hint would leave a focusable "Open the full resolution capture" link
   underneath it: a keyboard visitor could still tab to an invisible
   control and be sent to the very asset that just failed, instead of
   reaching the Retry button they can actually see. `display:none` is what
   takes it out of the focus order (visibility/pointer-events would not),
   and the frame keeps its height from `aspect-ratio` regardless. The hint
   lives inside the link, so this covers it too. */
.combined-frame.media-failed .combined-link{display:none}
.combined-caption{margin:14px 2px 0;color:var(--quiet);font-size:13px;line-height:1.55}
@media(max-width:700px){
  .combined-caption{font-size:12px}
}

/* ---------- Full-resolution image viewer ---------- */

/*
 * Shared interactive-preview contract, used by every prominent image on
 * the site: index.html's hero capture and product-proof stage, and
 * faces/index.html's captures and Face showcases. Identical on every page
 * that uses it, so it lives here instead of being duplicated per page.
 *
 * This is a NATIVE <dialog> opened with showModal()
 * (FAC-SITE-TELEMETRY-FIRST-001 revision 4). The platform supplies
 * Escape-to-close, focus containment, focus restoration to the element
 * that opened it, top-layer stacking above everything else on the page,
 * and inertness of all content behind it. The previous hand-rolled
 * div[role="dialog"] reimplemented every one of those, including an
 * `inert` loop over each sibling element, which is a lot of surface area
 * for something the browser already gets right.
 *
 * DISPLAY IS SCOPED TO [open] ON PURPOSE. The UA stylesheet hides a closed
 * dialog with `dialog:not([open]){display:none}`; an unconditional author
 * `display:grid` here would beat that rule and leave the viewer painted
 * over the page at all times. This is exactly the specificity/origin trap
 * described in AGENTS.md, in a place where the losing rule belongs to the
 * browser rather than to another file in this repo.
 *
 * Two display modes, because the media is two different shapes and
 * neither may be cropped or stretched:
 *   strip  the 2560 x 720 Faces, kept at a readable width and scrolled
 *          sideways (a whole-viewport fit would be an unreadable sliver);
 *   fit    the near-square 2560 x 2160 combined captures, shown whole
 *          inside the viewport.
 */
.face-viewer{
  position:fixed;
  inset:0;
  z-index:1000;
  width:100%;
  max-width:100%;
  height:100%;
  max-height:100%;
  margin:0;
  border:0;
  padding:max(18px,env(safe-area-inset-top)) 16px max(18px,env(safe-area-inset-bottom));
  grid-template-rows:auto 1fr auto;
  gap:12px;
  background:rgba(2,5,7,.97);
  color:var(--ink);
  overflow:hidden;
}
.face-viewer[open]{display:grid}
.face-viewer::backdrop{background:rgba(2,5,7,.86)}
.viewer-close{justify-self:end;border:1px solid var(--line-hi);border-radius:8px;background:var(--panel);color:var(--ink);padding:10px 15px;font:600 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;cursor:pointer}
.viewer-close:hover,
.viewer-close:focus-visible{border-color:var(--cyan);color:var(--cyan)}
/* NO `justify-content:center` HERE. Centering an overflowing child with
   justify-content in a scroll container makes the overflow spill past the
   START edge, where it cannot be scrolled back to: the visitor lands in
   the middle of the 2560px-wide Face and can never reach its left side.
   That was measured on a 390px viewport during this revision's visual QA
   (the viewer opened showing the middle of the Elite Face with the left
   third unreachable). `margin:auto` on the image below centers it when it
   FITS and leaves it at the start edge when it does not, which is the
   behaviour both modes need. */
.viewer-scroll{min-width:0;overflow:auto;display:flex;align-items:center;-webkit-overflow-scrolling:touch;scrollbar-width:thin}
/* flex:none because a flex item's automatic minimum size comes from the
   image's own intrinsic width, so a declared width larger than the
   container got shrunk to whatever file was loaded: at 390px `width:900px`
   rendered as 640px for the preview and 900px for the upgrade, resizing the
   picture mid-view. Both panning modes want the declared width honoured and
   the container scrolled. */
.viewer-scroll img{display:block;height:auto;margin:auto;flex:none}
.face-viewer[data-viewer-mode="strip"] .viewer-scroll img{width:max(1250px,100vw);max-width:none}
/* A CONTAIN BOX, not a shrink-to-fit one (FAC-SITE-MEDIA-POLISH-001). The
   viewer now opens on the derivative the page already had and swaps in the
   optimized 2560 asset once it has decoded, so one box is filled by two
   images of different intrinsic widths. The previous `width:auto` sized
   that box from whichever file was loaded, and the swap visibly resized
   the picture mid-view: measured 640x540, then 945x797, at 1440x900.
   Sizing the ELEMENT from its container and letting object-fit place the
   picture inside it makes the geometry identical before and after;
   `contain` is what keeps that from being a stretch, since the picture is
   still drawn whole at its own ratio, centered. */
.face-viewer[data-viewer-mode="fit"] .viewer-scroll img{width:100%;height:100%;max-width:100%;max-height:100%;object-fit:contain}
.viewer-note{margin:0;color:var(--quiet);font:500 9px var(--mono);letter-spacing:.08em;text-align:center;text-transform:uppercase}
.viewer-note:empty{display:none}
.viewer-note[data-viewer-status="error"]{color:var(--amber)}
body.viewer-open{overflow:hidden}

/*
 * POINTER-CLOSE FOCUS QUIETING (FAC-SITE-MEDIA-POLISH-001).
 *
 * A modal <dialog> restores focus to its trigger, which is right, but
 * Chromium then matches :focus-visible there whether or not a keyboard was
 * ever involved. Measured on current main: a plain mouse click into a
 * combined capture and a click on Close left the capture computing
 * `outline: 2px solid rgb(18,230,238)`.
 *
 * assets/site-face-viewer.js records how the viewer was opened and marks
 * the restored trigger with this attribute for POINTER opens only, then
 * removes it on the first blur or key press. One ring, one interaction:
 * keyboard visitors never get the attribute, nothing here weakens
 * :focus-visible globally, and the next Tab or Enter behaves normally.
 *
 * Written against the two trigger SHAPES, not their page classes: the
 * rings are declared in three different files (.combined-link above,
 * index.html's .stage-shot, the UA default on faces/index.html's
 * .face-frame), and (0,2,1)/(0,3,0) outrank all three on specificity
 * rather than on which stylesheet the browser read last.
 */
a[data-viewer-quiet-focus]:focus-visible,
[role="button"][data-viewer-quiet-focus]:focus-visible{outline:none}
@media(max-width:700px){
  .face-viewer[data-viewer-mode="strip"] .viewer-scroll img{width:1280px}
  /* A near-square 2560 x 2160 capture fitted into a phone-width viewport
     is technically whole and practically unreadable: the Face text below
     the game is a few pixels tall. At this width the capture gets the same
     treatment as the Face strip instead, rendered large enough to read and
     panned in both axes, which is still the complete image with no crop
     and no stretch. */
  /* height:auto restores natural sizing here: at a fixed 900px width the
     box already follows the capture's ratio, and every derivative of a
     2560 x 2160 capture shares that ratio, so the preview and the upgrade
     produce the same box without needing a contain box at all. */
  .face-viewer[data-viewer-mode="fit"] .viewer-scroll img{width:900px;height:auto;max-width:none;max-height:none}
}

/* ---------- Section rhythm ---------- */

/* This is a default for pages that don't need their own rhythm (index.html,
   early-access, privacy). A page that wants different section padding must
   scope its override as `main .section{...}` (0,1,1), not a bare
   `.section{...}` (0,1,0) — equal specificity means whichever stylesheet
   loads last in the DOM wins, and that order differs between a direct load
   (page <style> before this file) and site-navigation.js's SPA swap (which
   appends the destination page's <style> after this file, since only
   data-site-shell is left mounted). A bare-.section override on a page
   silently renders the WRONG padding on whichever load path loses that
   race — see AGENTS.md, "The recurring failure mode", for the same
   equal-specificity bug shape. engineering/index.html, faces/index.html,
   how-it-works/index.html, and alpha/alpha.css all correctly use
   `main .section` for this reason; don't "simplify" them back to `.section`. */
.section{padding:84px 0;border-top:1px solid var(--line)}
@media(max-width:700px){
  .section{padding:40px 0}
}

/* ---------- Page header (sticky-header clearance) ---------- */

/* The one shared offset rule for every public sub-page's first heading
   (engineering, how-it-works, faces, FAQ): a fixed 56px/34px gap below
   `body main`'s nav offset, not a page-specific magic number. FAQ used to
   keep its own `.faq-top` copy of this plus a `@media(min-width:1000px)`
   override that ballooned it to 132px/104px on desktop — an oversized gap
   between the nav and the first heading that this rule removes by giving
   every page using `.page-top` the same number instead of letting one
   page drift. A page only needs its own `<style>` for content that's
   genuinely different (h1 max-width, an extra jump-nav row), not for the
   padding itself. */
.page-top{padding:56px 0 34px;border-bottom:1px solid var(--line)}
.page-top h1{margin:0;max-width:760px;font-size:clamp(40px,6.5vw,68px);line-height:1;letter-spacing:-.05em}
.page-top>.shell>p:not(.eyebrow){max-width:680px;margin:20px 0 0;color:var(--muted);font-size:17px;line-height:1.6}
@media(max-width:700px){
  .page-top{padding:36px 0 26px}
  .page-top h1{font-size:clamp(32px,10vw,42px)}
}
