/* zai-auth base template — design tokens + chrome, ported from
   docs/design_handoff_auth_page/SharedComputer.dc.html. Hover states are
   plain CSS (:hover/:focus-within) since this is server-rendered Django with
   no client router to fake them, unlike the original prototype. */

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("../fonts/inter/Inter-latin-variable.260c81a4759b.woff2") format("woff2");
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/ibm-plex-mono/IBMPlexMono-Regular-latin.79936b18df9f.woff2") format("woff2");
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../fonts/ibm-plex-mono/IBMPlexMono-Medium-latin.b1c8a895f5fd.woff2") format("woff2");
}

:root {
  --bg: oklch(0.16 0.006 255);
  --surface: oklch(0.19 0.007 255);
  --inset: oklch(0.15 0.006 255);
  --border: oklch(0.28 0.008 255);
  --border-input: oklch(0.3 0.01 255);
  --border-hover: oklch(0.4 0.01 255);
  --text: oklch(0.94 0.004 255);
  --text-secondary: oklch(0.68 0.008 255);
  --text-muted: oklch(0.55 0.008 255);
  --text-muted-2: oklch(0.5 0.008 255);
  --text-disabled: oklch(0.42 0.008 255);
  --accent: oklch(0.62 0.17 255);
  --accent-hover: oklch(0.67 0.17 255);
  --success: oklch(0.7 0.16 150);
  --danger: oklch(0.75 0.09 20);
  --danger-hover-bg: oklch(0.24 0.03 20);
  --nav-active-bg: oklch(0.26 0.01 255);
  --font-sans: "Inter", -apple-system, Helvetica, Arial, sans-serif;
  --font-mono: "IBM Plex Mono", monospace;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
}

::selection {
  background: oklch(0.55 0.16 255 / 0.35);
}

input::placeholder {
  color: oklch(0.55 0.01 255);
}

a {
  color: inherit;
}

.page {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

/* --- Nav ------------------------------------------------------------- */

.nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 28px;
  height: 64px;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}

.nav__brand {
  display: flex;
  align-items: center;
  gap: 10px;
  text-decoration: none;
  color: var(--text);
}

.nav__brand-text {
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

.nav__brand-accent {
  color: var(--accent);
}

/* The two ends of the nav. `.nav` itself is space-between, so each end is one
   flex item however many things are in it, which is what lets the left end
   grow an About menu without the right end moving. */
.nav__group,
.nav__items {
  display: flex;
  align-items: center;
  gap: 4px;
}

.nav__item {
  padding: 8px 14px;
  border-radius: 7px;
  font-size: 13.5px;
  font-weight: 500;
  color: var(--text-secondary);
  text-decoration: none;
  transition: background 0.15s, color 0.15s;
}

.nav__item:hover {
  background: var(--nav-active-bg);
  color: var(--text);
}

.nav__item.is-active {
  background: var(--nav-active-bg);
  color: var(--text);
}

/* A surface this visitor can see but not open yet — GATE's nav-side form. It
   keeps the row's shape so the nav does not reflow when someone is let in, and
   drops every affordance that would suggest otherwise: no hover, no pointer,
   and dimmed to the token that already means "present, not available". */
.nav__item--closed {
  color: var(--text-disabled);
  cursor: default;
}

.nav__item--closed:hover {
  background: none;
  color: var(--text-disabled);
}

.nav__divider {
  width: 1px;
  height: 22px;
  background: var(--border);
  margin: 0 8px;
}

.nav__account {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 8px 14px;
  border-radius: 7px;
  font-size: 13.5px;
  font-weight: 500;
  color: var(--text-secondary);
  text-decoration: none;
}

.nav__account:hover,
.nav__account.is-active {
  background: var(--nav-active-bg);
  color: var(--text);
}

/* --- Account menu (nav) -------------------------------------------------- */

.nav__menu {
  position: relative;
}

/* Menu triggers open on hover, so they are labels, not things to click. */
.nav__menu .nav__account,
.nav__item--menu {
  cursor: default;
}

.nav__item--menu {
  display: block;
}

.nav__menu:hover .nav__account,
.nav__menu:focus-within .nav__account,
.nav__menu:hover .nav__item--menu,
.nav__menu:focus-within .nav__item--menu {
  background: var(--nav-active-bg);
  color: var(--text);
}

.nav__dropdown {
  display: none;
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 10;
  min-width: 220px;
  padding: 6px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  box-shadow: 0 10px 30px oklch(0 0 0 / 0.45);
}

/* Mid-nav menus hang from their left edge; the account menu, last in the row,
   from its right so it cannot run off the viewport. */
.nav__dropdown--left {
  left: 0;
  right: auto;
}

/* Wide enough for a DID on one line. */
.nav__dropdown--account {
  min-width: 320px;
}

/* Bridges the 6px gap under the chip, so the pointer can travel from one to the
   other without passing over dead space and closing the menu. */
.nav__dropdown::before {
  content: "";
  position: absolute;
  top: -7px;
  left: 0;
  right: 0;
  height: 7px;
}

.nav__menu:hover .nav__dropdown,
.nav__menu:focus-within .nav__dropdown {
  display: block;
}

/* Labelled facts, not actions — identity, not controls. One shared row style so
   name, DID and membership read as a set. The name row is conditional, which is
   why the top padding hangs off :first-child rather than off the DID. */
.nav__dropdown-meta {
  padding: 4px 10px;
  font-size: 12.5px;
  color: var(--text-muted);
  white-space: nowrap;
  overflow-x: auto;
}

.nav__dropdown-meta:first-child {
  padding-top: 8px;
}

.nav__dropdown-value {
  color: var(--text-secondary);
}

/* The DID stays monospaced — it is a string to read character by character —
   and on one line, since a DID broken mid-string is a DID copied wrong. */
.nav__dropdown-value--did {
  font-family: var(--font-mono);
  font-size: 12px;
}

/* --border-hover, not --border: a 1px line reads as a group boundary only if it
   is a step above the surface it sits on, and the panel's own --border is too
   close to --surface to survive at that height. The extra vertical margin does
   half the separating on its own. */
.nav__dropdown-rule {
  height: 1px;
  background: var(--border-hover);
  margin: 7px 0;
}

.nav__dropdown-item {
  display: block;
  padding: 9px 10px;
  border-radius: 7px;
  font-size: 13.5px;
  font-weight: 500;
  color: var(--text-secondary);
  text-decoration: none;
}

.nav__dropdown-item:hover {
  background: var(--nav-active-bg);
  color: var(--text);
}

/* The page the reader is already on. Colour alone, no background: the hover
   highlight has to stay the thing that tracks the pointer, and two lit rows in
   a three-row menu would make it ambiguous which one the click lands on. */
.nav__dropdown-item.is-active {
  color: var(--text);
}

/* Names a group of items inside a menu. Small, quiet and uppercase so it reads
   as a heading rather than as a first entry somebody might try to click —
   .nav__dropdown-meta is the wrong thing here, since that styles a labelled
   *fact* in the account panel rather than a heading over a list. */
.nav__dropdown-label {
  padding: 8px 10px 4px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted-2);
}

/* The dropdown's version of .nav__item--closed: present, visibly not open yet.
   Same reasoning, same tokens — a member-only app stays listed for a
   non-member, because the menu is where the cluster says what it has. */
.nav__dropdown-item--closed {
  color: var(--text-disabled);
  cursor: default;
}

.nav__dropdown-item--closed:hover {
  background: none;
  color: var(--text-disabled);
}

.nav__status-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: oklch(0.5 0.01 255);
  flex-shrink: 0;
}

.nav__status-dot.is-online {
  background: var(--success);
}

/* --- Main / layout ----------------------------------------------------- */

.main {
  flex: 1;
  display: flex;
  flex-direction: column;
}

/* --- Login view --------------------------------------------------------- */

.login-view {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px 20px;
}

/* 480px, not the 380px this was ported at: the copy outgrew the box. Both the
   home page's title and the "Create one on Bluesky" hint — which the login page
   carries too — wrapped mid-phrase at 380. */
.login-box {
  width: 100%;
  max-width: 480px;
}

.login-header {
  text-align: center;
  margin-bottom: 32px;
}

.login-header h1 {
  font-size: 26px;
  font-weight: 600;
  margin: 0 0 8px;
  letter-spacing: -0.01em;
}

.login-header p {
  font-size: 14px;
  color: var(--text-secondary);
  margin: 0;
  line-height: 1.5;
}

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 28px;
}

/* Stacked cards (the login page's real form + the dev sign-in box) need to read
   as two separate offers, not one panel with a seam. */
.card + .card {
  margin-top: 28px;
}

/* A card introduced by a line of page copy, with no .section-title between them
   to supply the gap. .page-text's 4px is sized for a paragraph followed by
   another paragraph; a card is a different kind of object and has to sit clear
   of the sentence that introduces it.

   The two selectors land on the same 20px because adjacent margins collapse: the
   .alert case keeps its own 14px bottom margin, and max(14, 20) is what shows.
   So a page reads the same whether or not there is a notice on it. */
.page-text + .card,
.alert + .card {
  margin-top: 20px;
}

.card__title {
  font-size: 15px;
  font-weight: 600;
  margin: 0 0 16px;
}

.field-label-row {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 8px;
}

.field-label-row label {
  font-size: 13px;
  font-weight: 500;
  color: oklch(0.82 0.006 255);
}

.info-dot {
  width: 15px;
  height: 15px;
  border-radius: 50%;
  border: 1px solid oklch(0.5 0.01 255);
  color: oklch(0.58 0.01 255);
  font-size: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: default;
  flex-shrink: 0;
}

.input-row {
  display: flex;
  align-items: center;
  background: var(--inset);
  border: 1px solid var(--border-input);
  border-radius: 9px;
  padding: 0 14px;
  margin-bottom: 14px;
}

.input-row:focus-within {
  border-color: var(--accent);
}

.input-row__prefix {
  color: oklch(0.55 0.01 255);
  font-size: 14px;
  font-family: var(--font-mono);
  margin-right: 2px;
}

/* --- Console row controls ------------------------------------------------ */
/* The membership decisions, which live inside table cells. Each is its own
   form — one per row, since each carries its own DID — so they need to sit
   side by side without the block layout a bare <form> brings. */

.row-form {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin: 0 6px 0 0;
}

.select {
  background: var(--inset);
  border: 1px solid var(--border-input);
  border-radius: 7px;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 12.5px;
  padding: 5px 8px;
}

.select:focus {
  border-color: var(--accent);
  outline: none;
}

/* Matches .select exactly apart from width — the two sit in the same row-forms
   and a text field that didn't would read as a different kind of control. */
.text-input {
  background: var(--inset);
  border: 1px solid var(--border-input);
  border-radius: 7px;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 12.5px;
  padding: 5px 8px;
  min-width: 22ch;
}

.text-input:focus {
  border-color: var(--accent);
  outline: none;
}

/* The one row-form that is not in a table cell: it closes the card below the
   list, so the rule is what separates inviting from the roster it adds to. */
.row-form--add {
  border-top: 1px solid var(--border);
  display: flex;
  margin: 20px 0 0;
  padding-top: 20px;
}

/* The handle being typed is the whole content of this form, and unlike the
   selects beside it there is no bound on how long one gets. It takes the row's
   slack so a full `name.example.social` is readable while it is typed rather
   than scrolling inside a control sized for a tier slug. */
.row-form--add .text-input {
  flex: 1;
  min-width: 24ch;
}

/* The service-session lock, in the card's top-right corner. Its own flow row
   rather than absolute positioning, so it cannot land on top of the roster
   error. The negative margins pull it back into the card's padding. */
.card__corner {
  display: flex;
  justify-content: flex-end;
  margin: -12px -8px 20px 0;
}

/* The way out of a panel, in the card's top-right corner: quiet, because it is
   the escape from the card rather than a control the card is for. Same size and
   colour as a link inside .hint-text, so the understated links on a page look
   like one thing.

   Out of flow, and that is the difference from .card__corner above. The lock
   takes a flow row precisely so it can never land on the console card's error
   line; here the top of the card is one short left-aligned field label, nothing
   else can arrive there, and a flow row would push the first field down by the
   height of a link that is not part of the form. `top`/`right` match .card's
   padding so it sits level with that first label. */
.card--dismissable {
  position: relative;
}

.card__dismiss {
  position: absolute;
  top: 28px;
  right: 28px;
  font-size: 12px;
  color: oklch(0.68 0.15 255);
  text-decoration: none;
}

.lock-form {
  display: inline;
}

/* Three signals for one bit, because it was one before and a reader could not
   tell 🔒 from 🔓 at a glance: the shackle is open or shut, the colour is the
   page's own success token or muted, and the word says which. Colour alone is
   unreadable to anyone who cannot distinguish the two and invisible to a screen
   reader — the same reason .status carries a word beside its dot.
   The two states are also different elements, and the words follow: a <span>
   states what is true, a <button> says what pressing it does. */
.lock {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: none;
  border: 1px solid transparent;
  border-radius: 8px;
  cursor: default;
  font-family: inherit;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 5px 9px;
}

.lock__icon {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  flex-shrink: 0;
}

.lock--open {
  color: var(--success);
}

/* Locked is the resting state, not a fault, so it is muted rather than red —
   but it is the only one of the two that is pressable, and it carries a
   button's outline to say so. */
.lock--closed {
  color: var(--text-secondary);
  border-color: var(--border-input);
  cursor: pointer;
}

.lock--closed:hover {
  border-color: var(--border-hover);
  color: var(--text);
}

/* A control that is present but cannot act says why on hover. Kept legible
   rather than nearly invisible: it is explaining itself, not decoration. */
.btn-row:disabled {
  cursor: not-allowed;
  opacity: 0.45;
}

/* Not .btn-primary: that one is full-width and block, which is right for the
   one action a page is about and wrong for a control repeated down a column. */
.btn-row {
  background: var(--surface);
  border: 1px solid var(--border-input);
  border-radius: 7px;
  color: var(--text);
  cursor: pointer;
  font-family: inherit;
  font-size: 12.5px;
  padding: 6px 12px;
  white-space: nowrap;
}

.btn-row:hover {
  border-color: var(--border-hover);
}

/* Revoking ends someone's access. It stays a button like any other until the
   pointer is on it, then says what kind of button it is. */
.btn-row--danger:hover {
  background: var(--danger-hover-bg);
  border-color: var(--danger);
  color: var(--danger);
}

.hint-inline {
  color: var(--text-disabled);
  font-size: 12px;
}

/* The prefix as a label rather than a sigil. `@` wants to sit flush against
   what follows it; a word does not. */
.input-row__prefix--label {
  margin-right: 10px;
  white-space: nowrap;
}

.input-row input {
  flex: 1;
  min-width: 0;
  background: transparent;
  border: none;
  outline: none;
  color: var(--text);
  font-size: 14px;
  padding: 12px 0;
  font-family: var(--font-mono);
}

.btn-primary {
  width: 100%;
  padding: 12px;
  border-radius: 8px;
  border: none;
  background: var(--accent);
  color: oklch(0.99 0.005 255);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}

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

.btn-primary:disabled {
  opacity: 0.75;
  cursor: default;
}

/* The signed-out home page uses .btn-primary on an <a>, which is neither
   block-level nor un-underlined by default. Everything else about the button
   is identical, so restate only what an anchor gets wrong. */
a.btn-primary {
  display: block;
  text-align: center;
  text-decoration: none;
}

/* An applicant's own note, read back to them. Set apart deliberately: it is
   the one thing on the page they wrote, and it is public, so it should look
   like something quoted rather than like more of our prose. */
.quote {
  margin: 0 0 14px;
  padding: 8px 14px;
  border-left: 2px solid var(--border);
  color: var(--text-secondary);
  font-size: 14px;
  line-height: 1.6;
}

.hint-text {
  font-size: 12px;
  color: oklch(0.52 0.008 255);
  text-align: center;
  margin: 16px 0 0;
  line-height: 1.5;
}

.hint-text a {
  color: oklch(0.68 0.15 255);
  text-decoration: none;
}

.alert {
  font-size: 13px;
  color: var(--danger);
  background: var(--danger-hover-bg);
  border: 1px solid var(--border);
  border-radius: 9px;
  padding: 10px 14px;
  margin-bottom: 14px;
}

/* .alert is red because its usual job is a failed login. "You're not a member
   yet" is a state, not an error, so it borrows the shape and drops the alarm. */
.alert--notice {
  color: var(--text-secondary);
  background: var(--inset);
}

.alert--notice strong {
  color: var(--text);
}

/* --- Home, signed out ---------------------------------------------------- */

/* Prose only — no card, no button. The nav carries the sign-in link. */
.intro {
  flex: 1;
  max-width: 560px;
  margin: 0 auto;
  width: 100%;
  padding: 64px 24px;
}

.intro h1 {
  font-size: 28px;
  font-weight: 600;
  letter-spacing: -0.01em;
  margin: 0 0 16px;
}

.intro p {
  font-size: 15px;
  line-height: 1.6;
  color: var(--text-secondary);
  margin: 0 0 14px;
}

/* --- Account view -------------------------------------------------------- */

.account-view {
  flex: 1;
  max-width: 560px;
  margin: 0 auto;
  width: 100%;
  padding: 40px 24px;
}

.account-view h1 {
  font-size: 22px;
  font-weight: 600;
  margin: 0 0 24px;
}

/* Body copy inside the 560px column (e.g. /api/'s explanation). */
.page-text {
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--text-secondary);
  margin: 0 0 4px;
}

/* A URL shown to be copied, not typed into. Scrolls rather than wrapping —
   a base URL broken across lines is a base URL someone pastes wrong. */
.endpoint {
  font-family: var(--font-mono);
  font-size: 13.5px;
  background: var(--inset);
  border: 1px solid var(--border-input);
  border-radius: 9px;
  padding: 12px 14px;
  overflow-x: auto;
  white-space: nowrap;
}

/* Sits over a card the way "Account" sits over the account card — same weight
   and size, with the leading space a second section needs. */
.section-title {
  font-size: 22px;
  font-weight: 600;
  margin: 32px 0 16px;
}

/* Membership state line, standing alone on a member's otherwise empty home. */
.status-line {
  font-size: 13px;
  color: var(--text-secondary);
  margin: 0 0 16px;
}

.status-line--member::before {
  content: "";
  display: inline-block;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--success);
  margin-right: 8px;
  vertical-align: middle;
}

/* --- About pages --------------------------------------------------------- */

/* The about pages run wide: 80% of the window rather than the account pages'
   560px reading measure. They are the outward-facing description of the
   project, and they carry tables of machines and repositories that a narrow
   column pushes into their own scrollbars. Worn alongside .account-view, whose
   `width: 100%` this caps. */
.about-view {
  max-width: 80%;
}

/* .page-text is spaced for the inside of a card, where the padding does most
   of the separating and a run of paragraphs is rare. These pages are almost
   nothing but runs of paragraphs, so the gap has to be real. It is added here
   rather than by loosening .page-text everywhere it is already right. */
.about-view .page-text + .page-text {
  margin-top: 12px;
}

/* The one sentence that says what the page is, before the first section title.
   Full --text rather than --text-secondary: it is the page's claim, and the
   paragraphs under it are the elaboration. */
.about-lead {
  font-size: 16px;
  line-height: 1.6;
  margin: 0 0 8px;
}

/* A person's handle, under their name. The handle IS the identity on this
   cluster, the same string they sign in with, so it stands on its own line
   instead of being mentioned in the prose. */
.about-handle {
  font-size: 13px;
  color: var(--text-muted);
  margin: 0;
}

/* The team page's card header: face, name, handle. The name is an <h2> here
   rather than a .section-title above the card, because a picture and the name
   it belongs to have to sit on the same line to read as one person. */
.about-person {
  display: flex;
  align-items: center;
  gap: 16px;
  margin: 0 0 18px;
}

/* Loaded live from Bluesky, so its dimensions are fixed here and stated on the
   tag as well: the CDN is a third party, and a picture that arrives late must
   not push the paragraph under it down the page when it does. The background
   fills the circle for the same reason, before the image lands. */
.about-person__avatar {
  width: 64px;
  height: 64px;
  border-radius: 50%;
  object-fit: cover;
  background: var(--inset);
  border: 1px solid var(--border);
  flex-shrink: 0;
}

.about-person__name {
  font-size: 18px;
  font-weight: 600;
  margin: 0 0 4px;
}

/* The people are cards in a row with no section title between them, so the
   first one needs the gap the title used to provide. */
.about-lead + .card {
  margin-top: 28px;
}

/* Where the three about pages point at each other. The nav menu can do this
   too, but only for a reader who thinks to go back up to it. A page that has
   ended should say what there is to read next. */
.about-more {
  font-size: 13.5px;
  color: var(--text-muted);
  margin: 32px 0 0;
}

.about-more a {
  color: var(--accent);
  text-decoration: none;
}

.about-more a:hover {
  color: var(--accent-hover);
  text-decoration: underline;
}

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

/* Pinned to the bottom of the window, not to the bottom of the document. The
   build stamp is the quickest confirmation a deploy actually landed, and on the
   long pages this app now has it was several screens down.

   Sticky rather than fixed: it keeps its place in the flex column, so it still
   occupies real height and nothing has to be padded out from under it. On a
   page shorter than the window `.page`'s min-height already puts it at the
   bottom and sticky changes nothing; on a longer one it rides the viewport.

   Both of the other two properties are load-bearing. The background is what
   makes content scroll *under* it rather than through it — the footer had none,
   because until now nothing was ever behind it. The z-index sits below the
   nav's dropdowns (10) so an open menu still wins. */
.footer {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 22px;
  padding: 18px 24px;
  border-top: 1px solid var(--border);
  flex-shrink: 0;
  position: sticky;
  bottom: 0;
  z-index: 5;
  background: var(--bg);
}

/* The build stamp and the copyright are separated by .footer's own gap — no
   separator glyph between them, and nothing to lay out. */
.footer__copy,
.footer__version {
  font-size: 13.5px;
  color: var(--text-muted-2);
}

.footer__link {
  color: oklch(0.58 0.008 255);
  text-decoration: none;
}

.footer__link:hover {
  color: oklch(0.85 0.006 255);
}

/* --- Manage console ------------------------------------------------------ */

/* The wider measure, for pages that are mostly tables. 560px is right for a
   form and a paragraph, but the member roll is five columns of identifiers and
   timestamps, and at 560 it scrolled sideways inside its card to show columns
   that fit a wider page easily. Applied per page rather than to .account-view
   so the account pages keep the reading measure they were set at — /api/ opted
   in when it gained the key and usage tables. */
.console-view {
  max-width: 900px;
}

/* Section copy on a 900px page. .hint-text is centred because it was written
   for the login card, where it sits under a single button; across this width
   centred lines read as captions floating free of the table they describe. */
.console-view .hint-text,
.console-view .page-text {
  text-align: left;
}

/* DIDs are long and never wrap usefully, so the table scrolls inside its card
   rather than pushing the page sideways. */
.table-scroll {
  overflow-x: auto;
}

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}

.data-table th {
  text-align: left;
  font-weight: 600;
  font-size: 11px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
  padding: 0 16px 10px 0;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}

.data-table td {
  padding: 11px 16px 11px 0;
  border-bottom: 1px solid var(--inset);
  white-space: nowrap;
}

.data-table tr:last-child td {
  border-bottom: none;
}

/* Identifiers, monospace so a column of them lines up to be compared. A handle
   is what the console shows; the DID it stands for is on the element's title,
   which the dotted underline is there to advertise. */
.data-table__did,
.data-table__handle {
  font-family: var(--font-mono);
  font-size: 12px;
}

.data-table__handle[title] {
  cursor: help;
  text-decoration: underline dotted var(--border);
  text-underline-offset: 3px;
}

.data-table__muted {
  color: var(--text-muted);
}

/* A count rather than a label, so it centres under its heading. Left-aligned, a
   column of single digits sits against the edge of a cell sized by the word
   "Members" above it and reads as stray text; centred, the numbers line up as
   the column they are. On the heading too, so the two stay together. */
.data-table__count,
th.data-table__count {
  text-align: center;
}

/* A second line under a cell's primary text — today the member's name under
   their handle. Quiet and smaller on purpose: it is the thing a reader
   recognises, but the handle above it is the thing they match on, and a name
   set at the same weight would compete for that job. Not proportional either,
   since it shares a cell with monospaced text and the two misalign badly. */
.data-table__sub {
  margin-top: 2px;
  font-size: 11.5px;
  color: var(--text-muted);
}

/* A member's handle, which is also the way into their controls. It keeps the
   dotted underline the plain handle cells carry — the DID is still on the
   title, so the tooltip is still there to advertise — and adds the colour and
   the pointer that say this one is also a door. */
.handle-link {
  color: var(--text);
  text-decoration: underline dotted var(--border);
  text-underline-offset: 3px;
  cursor: pointer;
}

.handle-link:hover,
.handle-link:focus-visible {
  color: var(--accent);
  text-decoration-color: var(--accent);
}

/* The one handle that leaves the site rather than opening a panel. Without the
   mark the two are identical, and the tables sit one above the other — so the
   arrow is the whole difference between "opens their controls" and "opens
   Bluesky in a new tab". Not in the underline, and not selected with the
   handle when it is copied. */
.handle-link--out::after {
  content: "↗";
  display: inline-block;
  margin-left: 4px;
  font-size: 0.9em;
  color: var(--text-muted);
  text-decoration: none;
  user-select: none;
}

.handle-link--out:hover::after,
.handle-link--out:focus-visible::after {
  color: var(--accent);
}

/* --- Member modal -------------------------------------------------------- */

/* The controls that used to be a table column. Three forms, a tier select and
   two confirmations do not fit in a cell — they were what pushed the members
   table into sideways scrolling — so they moved to a panel that has room for
   them, and for the facts (the DID, who granted the membership) that were
   costing columns to show for every member at once when they are only ever
   read about one.

   Opened by `:target` and closed by an ordinary link, so it works with no
   script at all: this is server-rendered Django, and a control that needs JS to
   open is a control that silently does nothing when it doesn't load. The cost
   is Escape not closing it, which is why the scrim is a link too — clicking
   anywhere outside the panel closes it, as it would in a real dialog. */
.modal {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 50;
  align-items: center;
  justify-content: center;
  padding: 24px;
}

.modal:target {
  display: flex;
}

.modal__scrim {
  position: absolute;
  inset: 0;
  background: oklch(0.1 0.005 255 / 0.72);
}

.modal__panel {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 460px;
  max-height: calc(100vh - 48px);
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 24px;
  box-shadow: 0 18px 50px oklch(0 0 0 / 0.5);
}

.modal__title {
  font-family: var(--font-mono);
  font-size: 15px;
  font-weight: 500;
  margin: 0 28px 16px 0;
  word-break: break-all;
}

.modal__close {
  position: absolute;
  top: 16px;
  right: 16px;
  width: 26px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 7px;
  color: var(--text-muted);
  font-size: 18px;
  line-height: 1;
  text-decoration: none;
}

.modal__close:hover {
  background: var(--nav-active-bg);
  color: var(--text);
}

/* Labelled facts, not controls — the same shape the nav's account dropdown uses
   for DID and Membership, for the same reason. */
.modal__facts {
  margin: 0 0 20px;
  padding: 12px 14px;
  background: var(--inset);
  border: 1px solid var(--border);
  border-radius: 9px;
  font-size: 12.5px;
}

.modal__fact {
  display: flex;
  gap: 12px;
  padding: 3px 0;
}

.modal__fact dt {
  flex-shrink: 0;
  width: 8.5ch;
  color: var(--text-muted);
}

.modal__fact dd {
  margin: 0;
  min-width: 0;
  color: var(--text-secondary);
}

/* A DID here is the one place it is text rather than a tooltip: the panel is
   about exactly one member, so there is no column of them to keep short, and
   somebody who opened it to read the DID should be able to select it. */
.modal__fact dd.data-table__did {
  word-break: break-all;
}

/* One action per line. In a cell these sat shoulder to shoulder and read as a
   toolbar; with room they read as what they are — three separate writes, each
   with its own confirmation. */
.modal__actions {
  display: grid;
  gap: 10px;
}

.modal__actions .row-form {
  display: flex;
  gap: 8px;
  margin: 0;
}

.modal__actions .row-form .select {
  flex: 1;
}

.modal__actions .btn-row {
  padding: 8px 14px;
}

/* The tier pair is one control and one verb; the two standalone buttons each
   own their whole line, so the eye does not have to work out which of three
   things a short button belongs to. */
.modal__actions > .row-form--wide .btn-row,
.modal__actions > .btn-row {
  flex: 1;
  width: 100%;
}

/* The one free-text column: an applicant's note, which they wrote and which
   can be any length. Everything else in these tables is an identifier or a
   date and is better off refusing to wrap; this wraps inside a bounded width
   so one long note cannot stretch the table past the card. */
.data-table__note {
  white-space: normal;
  max-width: 38ch;
  min-width: 16ch;
}

/* State, not decoration: `active`/`current` is the only thing on these rows
   that a reader is really checking, so it carries the only colour. */
.pill {
  display: inline-block;
  font-size: 11px;
  padding: 3px 9px;
  border-radius: 999px;
  background: var(--inset);
  border: 1px solid var(--border);
  color: var(--text-muted);
}

.pill--active {
  color: var(--success);
  border-color: color-mix(in oklch, var(--success) 35%, var(--border));
}

.button-row {
  display: flex;
  gap: 10px;
  align-items: center;
  margin: 4px 0 0;
}

/* The preview beside the real action: same shape, no weight. Reconciling is
   safe and re-runnable, so neither button is styled as a warning. */
.btn-secondary {
  font-family: inherit;
  font-size: 14px;
  font-weight: 500;
  padding: 11px 18px;
  border-radius: 9px;
  border: 1px solid var(--border-input);
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
}

.btn-secondary:hover {
  border-color: var(--border-hover);
  color: var(--text);
}

.finding-list {
  margin: 0 0 16px;
  padding-left: 18px;
  font-size: 13px;
  color: var(--text-secondary);
  line-height: 1.7;
}

/* --- Systems ------------------------------------------------------------- */

/* A status light and its label. The dot alone would carry the meaning in
   colour only, which is unreadable to anyone who can't distinguish them and
   invisible to a screen reader — so the word is always there beside it and
   the dot is decoration. */
.status {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  white-space: nowrap;
}

.status__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex-shrink: 0;
}

.status__dot--up {
  background: var(--success);
}

.status__dot--down {
  background: var(--danger);
}

/* Unknown is grey, not red: nothing is claimed to be broken, only unmeasured.
   The distinction matters more now that "down" is real — a service nobody has
   an address for must not be coloured like one that failed to answer. */
.status__dot--unknown {
  background: oklch(0.5 0.01 255);
}

/* Why a row reads the way it does, when the reason is not the service's health:
   "static files — no service to probe". Sits beside the purpose rather than in
   the status cell, which stays one word wide. */
.status__note {
  color: var(--text-muted-2);
}

/* The caption under the intro, about staleness. Quieter than .page-text so it
   reads as a note on the table rather than a second paragraph of preamble. */
.page-text--note {
  font-size: 13px;
  color: var(--text-muted);
  margin-bottom: 20px;
}

/* --- Quickstart (/api/) --------------------------------------------------- */

/* Numbered steps over the two halves of the quickstart. Not a heading: they
   label a pair of blocks inside one card, where .section-title would claim the
   weight of a new section. */
.step-label {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 10px;
}

/* Every step but the first needs air above it. Stated as "not the first" rather
   than as a list of what can precede a step, because what precedes step 2
   differs between the two renders of this card — the key filler on an ordinary
   visit, a line of prose on the one where a key was just minted — and a rule
   enumerating siblings silently misses whichever case it was not written for. */
.step-label:not(:first-child) {
  margin-top: 22px;
}

/* .endpoint's multi-line sibling: same inset well and mono face, but it holds
   a program rather than a URL. Wrapping is off for the same reason — a curl
   invocation folded at the viewport edge is one somebody pastes wrong — so a
   long line scrolls the block instead of the page. */
.code-block {
  position: relative;
  background: var(--inset);
  border: 1px solid var(--border-input);
  border-radius: 9px;
}

.code-block pre {
  margin: 0;
  padding: 14px 16px;
  overflow-x: auto;
  font-family: var(--font-mono);
  font-size: 13px;
  line-height: 1.55;
  /* Tab-sized indents would make the Python block drift right of everything
     else at the default 8. */
  tab-size: 2;
}

.code-block code {
  font: inherit;
}

/* Model names and field names in prose, at the weight the surrounding sentence
   is set in rather than a browser's default `monospace` bump. */
.inline-code {
  font-family: var(--font-mono);
  font-size: 0.92em;
  color: var(--text);
}

/* Sits over the scrolling <pre>, not in it, so it stays put while a long line
   scrolls under it. Revealed by api.js — see there for why it ships hidden. */
.copy-btn {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 1;
  font-family: inherit;
  font-size: 11px;
  font-weight: 500;
  padding: 5px 10px;
  border-radius: 7px;
  border: 1px solid var(--border-input);
  background: var(--surface);
  color: var(--text-secondary);
  cursor: pointer;
}

.copy-btn:hover {
  border-color: var(--border-hover);
  color: var(--text);
}

.copy-btn--done {
  color: var(--success);
  border-color: color-mix(in oklch, var(--success) 35%, var(--border));
}

/* --- Quickstart tabs ----------------------------------------------------- */

.tabs__bar {
  display: flex;
  gap: 4px;
  margin-bottom: 10px;
}

/* Out of sight, still in the tab order and still a radio group to a screen
   reader. `display: none` or `visibility: hidden` would take all three off the
   keyboard and leave the labels as decoration nobody can operate. */
.tabs__radio {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.tabs__label {
  font-size: 12.5px;
  font-weight: 500;
  padding: 7px 13px;
  border-radius: 8px;
  border: 1px solid transparent;
  color: var(--text-muted);
  cursor: pointer;
  user-select: none;
}

.tabs__label:hover {
  color: var(--text-secondary);
}

.tabs__radio:checked + .tabs__label {
  background: var(--nav-active-bg);
  border-color: var(--border);
  color: var(--text);
}

/* The focus ring the hidden radio can no longer draw for itself. */
.tabs__radio:focus-visible + .tabs__label {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Panes are visible by default and hidden only where `:has()` can select the
   checked one. A browser without it shows all three at once — a worse page,
   and a complete one. Nothing here is ever a blank card. */
@supports selector(:has(*)) {
  .tabs__pane {
    display: none;
  }

  .tabs:has(#qs-bash:checked) .tabs__pane--bash,
  .tabs:has(#qs-python:checked) .tabs__pane--python,
  .tabs:has(#qs-node:checked) .tabs__pane--node {
    display: block;
  }
}

/* --- Quickstart key filler ----------------------------------------------- */

/* Ships hidden and is revealed by api.js: without script it does nothing, and
   an input that silently does nothing is worse than no input. */
.key-fill {
  margin-top: 12px;
}

.key-fill__label {
  display: block;
  font-size: 12.5px;
  color: var(--text-secondary);
  margin-bottom: 7px;
}

.key-fill__input {
  width: 100%;
  font-family: var(--font-mono);
  font-size: 13px;
  padding: 10px 12px;
  border-radius: 9px;
  border: 1px solid var(--border-input);
  background: var(--inset);
  color: var(--text);
}

.key-fill__input:focus {
  outline: none;
  border-color: var(--accent);
}

.key-fill__input::placeholder {
  color: var(--text-disabled);
}

/* --- Rendered forms ------------------------------------------------------- */

/* For the pages whose template says `{{ form }}` and nothing else. Django's div
   renderer emits one `<div>` per field holding a `<label>`, a `<ul
   class="errorlist">` when there is something wrong, and the widget; these rules
   are the whole of what makes that markup wear this site's clothes, so a new
   form needs no template work at all.

   The border sits on the control rather than on a wrapper, which is where
   .input-row puts it. That wrapper is a vertically centred flex row built around
   something one line tall, and it cannot hold a textarea; moving the border in
   is what lets one rule cover both, and it is what the renderer's markup offers
   to style anyway.

   Scoped to the class rather than written against `form`, because the console
   and /api/ are full of hand-written row-forms sitting in table cells, and they
   are a different kind of thing that must not pick these up. */
.stacked-form > div {
  margin-bottom: 14px;
}

.stacked-form label {
  display: block;
  margin-bottom: 8px;
  font-size: 13px;
  font-weight: 500;
  color: oklch(0.82 0.006 255);
}

.stacked-form input,
.stacked-form textarea {
  width: 100%;
  background: var(--inset);
  border: 1px solid var(--border-input);
  border-radius: 9px;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 14px;
  line-height: 1.5;
  outline: none;
  padding: 12px 14px;
}

.stacked-form input:focus,
.stacked-form textarea:focus {
  border-color: var(--accent);
}

.stacked-form textarea {
  display: block;
  resize: vertical;
}

.stacked-form ::placeholder {
  color: var(--text-disabled);
}

/* Django's own <ul class="errorlist">, made to read like the .alert it would
   otherwise duplicate: quieter, because it sits against the field it is about
   rather than at the top of the page speaking for the whole form. Left where the
   renderer puts it, above the control, so a screen reader meets the problem
   before the box it belongs to. */
.stacked-form .errorlist {
  list-style: none;
  margin: 0 0 8px;
  padding: 0;
  color: var(--danger);
  font-size: 13px;
  line-height: 1.5;
}

/* --- Workspaces ----------------------------------------------------------- */

/* A link wearing the row control's clothes, for the controls on this site that
   go somewhere rather than posting something: "New workspace" opens a page,
   "Edit" opens the panel below. Only the underline needs undoing, since
   .btn-row supplies the rest and `a { color: inherit }` means the colour is
   already right. */
a.btn-row {
  text-decoration: none;
}

/* A workspace reads before it edits: the name and description are text, and the
   form is a panel opened by `:target`. Same mechanism as the console's member
   panels, with no script: a control that needs JS to open is a control that
   does not open when the JS does not load.

   Exactly one of the two is ever on screen, so their order in the markup is
   free, which is what lets the form come first. CSS can hide what *follows* a
   `:target` and not what precedes it, and that adjacency is the whole trick. */
.workspace-panel {
  display: none;
}

.workspace-panel:target,
.workspace-panel.is-open {
  display: block;
}

.workspace-panel:target + .workspace-read,
.workspace-panel.is-open + .workspace-read {
  display: none;
}

/* The description and its one control share a line, the control on the right
   edge. `baseline` rather than `center` or `flex-start`, because the thing to
   line up is the two pieces of text: a description long enough to wrap would
   centre its whole block against a button and leave the button floating beside
   the middle of a paragraph, where baseline keeps it level with the first line
   however many there are. */
.workspace-read {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 24px;
}

/* The description takes the slack and the button keeps its size, so the button
   sits on the right edge whether the description is a sentence or a paragraph. */
.workspace-read .page-text {
  flex: 1;
  min-width: 0;
  margin: 0;
}

.workspace-read__edit {
  flex-shrink: 0;
}

/* Two columns: who, and the one control that row carries. A table of two
   auto-width columns splits its slack between them, which leaves the button
   floating just past the longest handle rather than on an edge. Handing the
   name column all of the slack puts the control where a row's action belongs,
   and keeps it in one vertical line down the table however long the handles
   above and below it run. */
.data-table--roster th:first-child,
.data-table--roster td:first-child {
  width: 100%;
}

.data-table--roster td:last-child {
  text-align: right;
}
