/* ============================================================
   etalonterminal.com, layout and components.

   Reads the token layer and adds nothing to it. Every color here
   is a var(). A literal hex in this file is a bug: it means a
   value escaped the palette, which is how the three sources that
   the palette exists to reconcile got out of step in the first
   place.
   ============================================================ */

@import "tokens.css";

/* ============================================================
   Fonts, self-hosted. Geist and Geist Mono, the app's two faces,
   SIL OFL. The licence sits beside the files in public/fonts/
   and ships with the site.

   Two variable woff2 files, 141 KB total, from the `geist` npm
   package (1.7.2). The app ships four static Geist TTFs because
   its layout was measured against those exact bytes; the site
   has no such measurement to protect, so the variable face is
   the right trade. The app names Geist Mono but does not ship it,
   so in the app it falls back to Cascadia Mono; here it is real.

   The weight RANGE is load bearing on a variable face: without
   it the browser renders the file's default instance only.
   ============================================================ */
@font-face {
  font-family: "Geist";
  src: url("/fonts/geist-vf.woff2") format("woff2-variations"),
       url("/fonts/geist-vf.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Geist Mono";
  src: url("/fonts/geist-mono-vf.woff2") format("woff2-variations"),
       url("/fonts/geist-mono-vf.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--ground);
  color: var(--text);
  font-family: var(--ui);
  font-size: 16px;
  line-height: 1.55;
  font-synthesis-weight: none;
}

/* 12px is the floor across the product, not a suggestion. Nothing
   sets a smaller size, which is why there is no .xs class here.

   .kicker, which this replaces, was 12px uppercase with 0.08em
   tracking: the "all-caps label everywhere" tell named in the
   direction's own list, on 18 elements. Fourteen of those were
   decorative eyebrows and are gone. Four were real <label>s for
   form fields, which is a job, so they kept the styling minus the
   costume and took a name that describes what they are. */
.field-label {
  font-size: 13px;
  color: var(--muted);
  margin: 0 0 6px;
  display: inline-block;
}

h1, h2, h3 { line-height: 1.2; margin: 0 0 16px; font-weight: 600; }
h1 { font-size: 26px; letter-spacing: -0.01em; }
h2 { font-size: 23px; }
h3 { font-size: 20px; }

p { margin: 0 0 16px; max-width: 68ch; color: var(--sec); }

/* Headings carry no top margin, which is right for the first one in a
   section and wrong everywhere else: a device name sitting directly on
   the last row of the previous table read as part of it. */
:is(p, table, ul, ol, dl, aside, .gap) + :is(h2, h3) { margin-top: 36px; }
p.lead { color: var(--text); font-size: 18px; }

/* A hovered link is one of the accent's two jobs on a page, the other
   being the tint on the single primary action. Nothing else is green. */
a { color: var(--text); text-decoration-color: var(--field); text-underline-offset: 3px; }
a:hover { color: var(--accent-hover); text-decoration-color: currentColor; }

code, .mono { font-family: var(--mono); font-size: 0.92em; }

/* Focus is never removed, only restyled. A keyboard user who cannot
   see where they are is locked out of the sign-in page. */
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: 2px;
}

.wrap { max-width: 880px; margin: 0 auto; padding: 0 24px; }

/* --- the strip ------------------------------------------------ */
/* The app's title bar, reproduced as the site's one piece of persistent
   chrome. Same height, same --rail ground, same wordmark tracking, so a
   reader arriving from the desktop app recognises the room.

   AMENDED 2026-10-09: the primary nav carries Lab, Support and the account
   slot. The reason below for keeping it to one link was that every other
   page opened on emptiness. That stopped being true for two of them: the
   Lab is on sale and Support names two mailboxes. Visitors could not find
   either without scrolling to the footer. Download, Changelog and
   Indicators still open on nothing (Indicators: "No indicator is listed
   for sale yet"), so R14 keeps all three out until they have content.
   The note below is kept as written so the change stays legible.

   THE PRIMARY NAV CARRIES ONE LINK, and that is a decision rather than an
   oversight. It used to carry Download and Changelog, and both of those
   pages open on a statement that there is nothing there. Two of three
   links leading to emptiness reads as an abandoned product, which is the
   opposite of what they are: honest placeholders. They stay one click
   away in the footer, which is the complete index, and they return to the
   strip when they have content. */
.strip {
  background: var(--rail);
  border-bottom: 1px solid var(--quiet);
}
.strip .wrap {
  display: flex; align-items: center; gap: 24px;
  min-height: 44px; flex-wrap: wrap;
}
/* ONE STRIP, THE SAME ON EVERY PAGE, since 2026-10-13. It runs the full
   width of the window with one gutter, whatever width the page under it
   is set to.

   It used to borrow .wrap's width from whichever page it was on, and
   five stylesheets set five: 880px here, 1180px on the account and
   public pages, 1264px on the home page and the Lab's offer, and the
   whole window inside the Lab. So the mark and the links moved every
   time a reader changed page, which is the one thing a title bar must
   not do. The gutter is the Lab's own, so the mark sits where it sits
   inside the Lab.

   header.strip > .wrap outranks the page stylesheets' .wrap rules on
   purpose (two classes and an element against two classes), so a new
   page width cannot move the strip again. */
header.strip > .wrap {
  max-width: none; width: auto;
  padding-inline: 26px;
}
.identity {
  display: inline-flex; align-items: baseline; gap: 8px;
  text-decoration: none;
}
/* The mark is --text and is never tinted. Identity law, from the kit. */
.identity svg { align-self: center; display: block; color: var(--text); }
.wordmark {
  font-size: 14px; font-weight: 600;
  letter-spacing: var(--track-wordmark);
  color: var(--text);
}
.product { font-size: 12px; color: var(--sec); }
.strip nav { margin-left: auto; display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
.strip nav a { color: var(--sec); text-decoration: none; font-size: 14px; padding: 5px 0; }
.strip nav a:hover, .strip nav a[aria-current="page"] { color: var(--text); }
/* The page a reader is on is underlined as well as lit (Ben, 2026-10-13):
   full ink alone is also what a link under the pointer looks like, so on
   its own it did not say which page this is. The line is the word's own
   width and takes the last 2px of the link's 5px foot, so the word does
   not move. The Lab's application draws the same line under Lab in its
   own strip (the etalon-lab repo, frontend/src/lab-web.module.css), so
   the two bars agree. It is a border here and not a shadow because a
   page has two shadows, the app's, and check-site.mjs holds it to them. */
.strip nav > a[aria-current="page"] { padding-bottom: 3px; border-bottom: 2px solid var(--text); }
/* An answer in the support chat that whoever is signed in has not opened
   (src/lib/render.js, markSupportNews). The link's own name says it in
   words; this is the same thing for the eye. */
.strip nav a.has-news { color: var(--text); }
.strip nav a.has-news::after {
  content: "";
  display: inline-block;
  width: 7px; height: 7px;
  margin-left: 6px;
  border-radius: 50%;
  background: var(--accent);
  vertical-align: 1px;
}

/* The account slot, signed in: who the reader is, and under it the two
   things done from anywhere. Markup is strip() in src/lib/render.js.

   The letter is the Profile's mark at the strip's size, so it is the
   same ground, edge and letter in both places. It is not a status: it
   takes no colour and says nothing but whose account this is.

   A <details>, so it opens with no script (direction 2.10). The open
   one lays a layer over the page behind its menu, which is the whole
   mechanism for closing it: a click anywhere else lands on the summary
   and shuts it. The menu is a control, so it gets a surface. */
.strip .who { position: relative; }
.strip .who > summary {
  display: flex; align-items: center; gap: 8px;
  padding: 3px 0;
  color: var(--sec); font-size: 14px;
  cursor: pointer; list-style: none;
}
.strip .who > summary::-webkit-details-marker { display: none; }
.strip .who > summary:hover,
.strip .who[open] > summary { color: var(--text); }
/* In the account, the reader's own slot is the one underlined: the same
   2px line Lab and Support get as the page, taken from the summary's 3px
   foot so nothing moves and the menu under it opens where it did. */
.strip .who > summary[aria-current] {
  color: var(--text);
  padding-bottom: 1px; border-bottom: 2px solid var(--text);
}
.who-mark {
  flex: none; display: grid; place-items: center;
  width: 24px; height: 24px;
  border: 1px solid var(--chrome); border-radius: var(--radius-pill);
  background: var(--raised); color: var(--text);
  font-size: 12px; font-weight: var(--weight-medium); line-height: 1;
}
/* An account's own picture, where it has one, in the letter's place and
   at its size. This is not imagery in direction 2.4's sense: it is the
   reader's, shown only to the reader, and cropped to the ring. */
img.who-mark { display: block; object-fit: cover; }
.who-name {
  max-width: 20ch;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.who-caret { flex: none; color: var(--muted); }
.strip .who[open] > summary::before {
  content: ""; position: fixed; inset: 0; z-index: 40;
  cursor: default;
}
.who-menu {
  /* The summary is 30px in a 44px strip, so 7px brings the menu to the
     strip's lower edge and the rest is the gap under it. */
  position: absolute; right: 0; top: calc(100% + 13px); z-index: 41;
  display: grid; min-width: 168px; padding: 4px;
  background: var(--panel);
  border: 1px solid var(--chrome); border-radius: var(--radius-surface);
  box-shadow: var(--shadow-drag);
}
.strip .who-menu a {
  padding: 7px 10px;
  border-radius: var(--radius-control);
  /* Drawn inside the row: the page's ring sits 2px out, which here is
     the menu's own edge. */
  outline-offset: -2px;
}
.strip .who-menu a:hover,
.strip .who-menu a:focus-visible { background: var(--raised); color: var(--text); }
/* On a phone the links get their own row under the wordmark, ranged left
   like the rest of the page. Left to wrap on their own they fit beside the
   wordmark at some widths and not others, and bunched against it when they
   did, so the strip looked different on every phone. */
@media (max-width: 560px) {
  .strip .wrap { row-gap: 2px; padding-block: 6px; }
  header.strip > .wrap { padding-inline: 20px; }
  .strip nav { margin-left: 0; width: 100%; gap: 20px; }
  /* The account keeps the right-hand end of its row, so its menu always
     opens inside the window. */
  .strip .who { margin-left: auto; }
  .who-name { max-width: 14ch; }
}

/* The mark closes itself once on arrival, and that is the whole motion
   budget for the site (direction 2.6). Nothing else animates in, and this
   never loops, because a thing that keeps verifying itself is decoration
   rather than a signature.

   The mark is two paths: the apex-right-base piece, then the separate
   left stroke that completes the triangle. So the sequence is not
   decorative sugar, it is the shape's own construction: the open figure
   arrives, then the piece that closes it. Second path only, 120ms behind.

   OPACITY ONLY, AND THAT IS LOAD BEARING. The obvious version animates a
   rotation, and on the previous mark that destroyed the logo: it was one
   path plus two <use> elements carrying rotate(120) and rotate(240) as
   presentation attributes, a CSS transform beats a presentation
   attribute, and any keyframe naming `transform` collapsed all three bars
   on top of each other into a single dash. It shipped that way for
   exactly one screenshot on 2026-09-08. The mark is two plain paths now
   and carries no rotation, but the rule stands: fade, never move.

   Under reduced motion the animation is removed rather than shortened.
   The tokens zero every duration, and a zero-duration animation with
   `both` fill holds its first keyframe forever, which here means an
   invisible mark. */
@keyframes resolve {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.identity svg path {
  animation: resolve var(--dur-460) var(--ease) both;
}
.identity svg path + path { animation-delay: var(--dur-120); }
@media (prefers-reduced-motion: reduce) {
  .identity svg path { animation: none; }
}

/* --- sections ------------------------------------------------- */
main { padding: 56px 0 80px; }
section + section { margin-top: 56px; padding-top: 56px; border-top: 1px solid var(--quiet); }

/* --- buttons -------------------------------------------------- */
/* Rule 4: the primary action is the app's Button.primary, --raised
   tinted with the accent and edged in it, --text label. Tinted,
   never filled: the white-filled button from tokens 1.0 was louder
   than anything in the product it fronts. Colour only on hover,
   no transform, no shadow. */
.btn {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 10px 18px;
  border-radius: var(--radius-control);
  font: inherit; font-size: 15px; font-weight: 500;
  text-decoration: none; cursor: pointer;
  border: 1px solid transparent;
  transition: background var(--dur-120) var(--ease), border-color var(--dur-120) var(--ease);
}
.btn-primary {
  background: color-mix(in srgb, var(--accent) 16%, var(--raised));
  border-color: color-mix(in srgb, var(--accent) 50%, var(--line));
  color: var(--text);
}
.btn-primary:hover {
  background: color-mix(in srgb, var(--accent) 26%, var(--raised));
  border-color: var(--accent);
}
.btn-primary:active { background: color-mix(in srgb, var(--accent) 26%, var(--active)); }

.btn-secondary { background: var(--raised); color: var(--text); border-color: var(--field); }
.btn-secondary:hover { border-color: var(--hover); }
.btn-secondary:active { background: var(--active); border-color: var(--hover); color: var(--text); }

.btn[aria-disabled="true"] {
  background: var(--raised); color: var(--muted);
  border-color: var(--line); cursor: not-allowed;
}

/* Styled in this file rather than with style="" attributes on the
   elements. The CSP sets style-src 'self' with no 'unsafe-inline',
   and CSP3 falls style-src-attr back to style-src, so an inline
   style attribute is blocked and the element renders unstyled.
   There is no style="" anywhere in this site, and that is load
   bearing.

   Rescued on 2026-09-08 out of the .absent block, which was
   deleted along with .panel, .chip and .notice when state stopped
   being signalled and started being written. The comment outlived
   its block because what it records is about the whole file. */

/* --- facts table ---------------------------------------------- */
.facts { width: 100%; border-collapse: collapse; font-size: 15px; }
.facts th, .facts td {
  text-align: left; padding: 12px 0;
  border-bottom: 1px solid var(--quiet); vertical-align: top;
}
/* padding-right, not 0, because a key that fills the 12rem column otherwise touches its own
   value with no gutter at all. It went unnoticed while every key was short; /security prints
   header and directive names as data, so `upgrade-insecure-requests` and
   `cross-origin-opener-policy` ran straight into their values. A column gap is not optional
   just because most rows happen not to need it. */
.facts th { color: var(--muted); font-weight: 400; width: 12rem; padding-right: 20px; }
.facts td { color: var(--text); }
.facts td.mono { word-break: break-all; }

/* ============================================================
   THE DOCUMENT GRID (direction 2.2)

   A reading column and a margin. The margin is where this site
   stops looking like every other dark landing page: it carries
   the provenance the product already tracks, as footnotes, which
   is a document convention older than any interface.

   Auto-placement does the alignment, with no script and no
   absolute positioning. A .note declares column 2, so the grid
   places it on the SAME ROW as the block it follows, which means
   a note lines up with its paragraph purely by sitting after it
   in the source. Put the note immediately after the element it
   annotates. Two notes on one paragraph go in one .note.
   ============================================================ */
.doc {
  display: grid;
  grid-template-columns: minmax(0, var(--measure)) var(--margin-col);
  column-gap: var(--gutter);
  align-items: start;
}
.doc > * { grid-column: 1; min-width: 0; }
.doc > .note { grid-column: 2; }
/* The column is the measure now, so a second one would fight it. */
.doc p { max-width: none; }

/* The note is prose in the text face, not a data readout. Only
   figures and dates inside it go mono, via the global .mono. */
.note {
  font-size: var(--note-size);
  line-height: 1.5;
  color: var(--muted);
  margin: 0 0 16px;
}
.note :is(p, ul) { font-size: inherit; color: inherit; margin: 0 0 8px; max-width: none; }
.note :last-child { margin-bottom: 0; }

/* The marker. Mono because it is a figure, 12px because that is
   the floor and a superscript does not get an exemption. */
.fn {
  font-family: var(--mono);
  font-size: 12px;
  vertical-align: super;
  line-height: 0;
  color: var(--muted);
  margin-left: 2px;
}
.note > .fn { vertical-align: baseline; margin: 0 6px 0 0; }

/* Below the fold-point the margin cannot hold a column, so the
   note falls in under the thing it annotates and stays with it. */
@media (max-width: 760px) {
  .doc { grid-template-columns: minmax(0, 1fr); }
  .doc > .note { grid-column: 1; margin-top: -8px; }
}

/* ============================================================
   THE GAP (direction 2.4 and 2.6)

   The mark's corner gap means absence, and this is that idea in
   one rule: a dashed hairline ABOVE a thing that is not published
   yet. It is deliberately not a container. A rounded dashed box
   is the generated empty-state card, which is the exact tell this
   replaces, so there is no border, no radius and no padding on
   the sides. The reason it is not published is written out in
   prose underneath, and the margin carries the note.
   ============================================================ */
.gap {
  border-top: 1px dashed var(--faint-mark);
  padding-top: 20px;
  margin-top: 28px;
}

/* --- the account nav ------------------------------------------- */
/* Lives in <main>, never in <header class="strip">. The strip's
   markup is asserted byte-identical across every page by check 7
   in tools/check-site.mjs; a nav that appears on five pages only
   would make it a third variant and hard-fail the gate. */
.subnav {
  display: flex; gap: 20px; flex-wrap: wrap;
  margin: 0 0 32px; font-size: 14px;
}
.subnav a { color: var(--sec); text-decoration: none; padding: 5px 0; }
.subnav a:hover { color: var(--accent-hover); }
.subnav a[aria-current="page"] { color: var(--text); }

@media (max-width: 600px) {
  main { padding: 40px 0 56px; }
  .facts th { width: auto; display: block; padding-bottom: 0; border: 0; }
  .facts td { display: block; padding-top: 4px; }
}

/* --- the device approve guard ----------------------------------- */
/* Inert for 800ms after paint, so a tap already queued when the page
   arrives cannot land on Approve before the card has been read.
   Done in CSS because this site runs no JavaScript. */
.arm { pointer-events: none; animation: armed 800ms step-end forwards; }
@keyframes armed { to { pointer-events: auto; } }

/* Motion is a preference, not a given. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
  /* NOT animation: none, and .arm keeps its full delay.
     Two separate faults avoided here. A blanket `animation: none` would leave .arm stuck at
     pointer-events: none forever, so the Approve button would be permanently dead for anyone
     who prefers reduced motion. And collapsing its duration to 0.01ms would silently delete
     the guard for exactly those users. The delay is not decoration, so it survives. */
  .arm { animation-duration: 800ms !important; }
}

/* --- code and label inputs -------------------------------------- */
.code-input {
  background: var(--raised);
  color: var(--text);
  border: 1px solid var(--field);
  border-radius: var(--radius-control);
  padding: 10px 14px;
  font: inherit;
  font-size: 16px;
  min-width: 16rem;
  max-width: 100%;
}
.code-input:hover { border-color: var(--hover); }
.code-input::placeholder { color: var(--muted); }
form + p, form p:last-child { margin-bottom: 0; }

/* Wide staff tables scroll instead of forcing the page past the viewport. */
.table-scroll { max-width: 100%; overflow-x: auto; }
.team-accounts { min-width: 960px; }
