:root {
  --bg: #0b0f16;
  --bg-raised: #141b26;
  --bg-hover: #1c2635;
  --edge: #253044;
  --text: #e8edf5;
  --text-dim: #8b98ad;
  --accent: #4da3ff;
  --accent-dim: #1d5fa8;

  /* Shelfcloud's blue carries "this is yours": the library count, an added
     tile's border, the Added pill. That used to be green, which made the most
     prominent colour on the page one the product does not otherwise use — the
     same mistake the Deck plugin's header made.
     Green is now kept for one thing only: confirming an action succeeded, on
     the sign-in result page. Not for state, and never for branding. */
  --ok: #35c26a;
  --danger: #ff6b6b;

  --radius: 10px;
  --gap: 14px;
  --header-h: 116px;
  /* How wide the app's CONTENT may get. The header keeps its full-width
     background and border; it is what sits inside that stops. On a 1900px
     screen without this the search field runs the whole window and the brand
     ends up far from the library count it belongs beside. */
  --page: 1440px;
}

* { box-sizing: border-box; }

/* The hidden attribute has to actually hide things.
 *
 * `hidden` is only `display: none` in the BROWSER's stylesheet, and any author
 * rule that sets a display beats it regardless of specificity. `.sheet-field`
 * sets `display: flex`, so rows marked hidden went on rendering: the settings
 * sheet offered "Your Luna" under a Luna toggle that was switched off, and the
 * JavaScript marking it hidden looked like it had done nothing.
 *
 * !important here on purpose. This is a reset, not a style: an element that
 * has been marked hidden should be gone whatever else is said about it, and
 * without it every future display rule has to remember this trap. */
[hidden] { display: none !important; }

html, body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif;
}

a { color: var(--accent); }

/* ---------------------------------------------------------------- header */

header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: linear-gradient(180deg, #0b0f16 70%, rgba(11, 15, 22, 0.9));
  border-bottom: 1px solid var(--edge);
  padding: 14px 20px 12px;
}

.bar {
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  width: 100%;
  max-width: var(--page);
  margin-inline: auto;
}

.brand {
  font-size: 19px;
  font-weight: 650;
  letter-spacing: 0.2px;
  margin-right: auto;
  display: flex;
  align-items: center;
  gap: 9px;
}

/* Was a ☁ text glyph sized with font-size. Now an image, so it needs real
   dimensions. width/height are set on the tag as well, so the header does not
   reflow between the HTML being parsed and the stylesheet arriving.

   Larger than it looks like it should be, on purpose. The mark's artwork fills
   only about 61% of its square viewBox — the cloud spans y 56..212 of 256 —
   so the box has to be noticeably bigger than the text beside it for the cloud
   to read as the same size. At 52px the visible cloud is ~32px against a
   45px-tall two-line brand block, which is what balances. */
.brand .mark {
  width: 52px;
  height: 52px;
  display: block;
  flex: none;
}

/* The glow: a second, lit copy of the mark fading in and out over the first.
 *
 * Only OPACITY animates. The brightness is baked into a static filter on this
 * layer, which every browser paints once and never has to interpolate. That
 * distinction is the whole point: an animated filter is what three rounds of
 * "still not glowing" on a real phone were spent on, while an opacity fade is
 * the best-supported animation there is and runs on the compositor rather than
 * through a repaint.
 *
 * It also cannot be clipped away. The lit copy occupies exactly the mark's own
 * 52 square pixels, so the collapsed header — 75px tall with overflow hidden —
 * has nothing to cut off. The old halo spilled 16px in every direction and
 * lost three sides of itself in there.
 */
.brand { position: relative; }

.brand::after {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  width: 52px;
  height: 52px;
  transform: translateY(-50%);
  /* Above the mark, not behind it. A flex item and a positioned pseudo-element
     both at z-index auto is exactly the case where paint order is arguable, and
     Chrome put this underneath: the lit copy rendered perfectly and was then
     covered by the original, which is why fading it in changed the picture by
     0.2 of a pixel value. Saying which one is on top removes the argument. */
  z-index: 1;
  background: url("/static/img/mark.svg") center / contain no-repeat;
  filter: brightness(1.9) saturate(1.45)
          drop-shadow(0 0 8px rgba(77, 163, 255, 0.85));
  opacity: 0;
  pointer-events: none;
  animation: mark-glow 4s ease-in-out infinite alternate;
}

@keyframes mark-glow {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* Asked for less motion: the glow arrives once and then stays.
 *
 * Not the loop, because a thing that pulses forever is exactly what that
 * setting is for and people set it for real reasons. Not nothing either — a
 * mark that never moves at all is indistinguishable from a broken one, which
 * is how an hour went on a device with this switched on.
 *
 * So: one slow fade, held at the end by fill-mode, starting a moment after
 * the page has settled so it is not lost in the load. */
/* ----------------------------------------------------------- scrollbars
   The platform default is a wide light bar with arrow buttons on each end,
   which on a dark panel reads as a piece of the operating system sitting on
   top of the page rather than part of it.
   Both syntaxes on purpose: scrollbar-width/-color is the standard one, and
   the -webkit- rules are what Steam's in-client browser understands — it runs
   an older Chromium than the desktop, and the Deck is where these panels are
   most often read. */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--edge) transparent;
}

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--edge);
  border-radius: 999px;
  /* A transparent border with background-clip keeps the thumb visually narrow
     while the grabbable track stays a full 10px. A 4px bar looks right and is
     unusable with a thumb or a trackpad. */
  border: 2px solid transparent;
  background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
  background: var(--text-dim);
  background-clip: content-box;
}
::-webkit-scrollbar-corner { background: transparent; }

@media (prefers-reduced-motion: reduce) {
  .brand::after,
  .app-nav-mine .nav-icon::after {
    animation: mark-glow-once 2.4s ease-in-out 0.8s forwards;
    opacity: 0;
  }
}

@keyframes mark-glow-once {
  from { opacity: 0; }
  to   { opacity: 0.7; }
}

/* Replaces the <h2> on the sign-in screen. Capped in % as well as px so it
   does not crowd the panel on a phone. */
.gate-logo {
  display: block;
  /* em, so it tracks the card's font-size on a television. 17.3em is the
     old 260px at the old 15px base. */
  width: min(17.3em, 72%);
  height: auto;
  margin: 0 auto 0.9em;
}

.doc-mark {
  width: 20px;
  height: 20px;
  vertical-align: -4px;
  margin-right: 7px;
}

.brand small {
  display: block;
  font-size: 11px;
  font-weight: 400;
  color: var(--text-dim);
  letter-spacing: 0;
}

.controls {
  display: flex;
  gap: 10px;
  margin-top: 12px;
  flex-wrap: wrap;
  width: 100%;
  align-items: center;
  max-width: var(--page);
  margin-inline: auto;
}

input[type="search"] {
  flex: 1 1 260px;
  /* Stops growing well before the row does. A search field a thousand pixels
     wide holds one short word and reads as a mistake; past this the row gives
     its slack to the space before the sync button instead. */
  max-width: 640px;
  min-width: 0;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  color: var(--text);
  padding: 9px 13px;
  font-size: 15px;
}

input[type="search"]:focus,
select:focus,
button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

select {
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  color: var(--text);
  padding: 9px 11px;
  font-size: 14px;
}

button {
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  color: var(--text);
  padding: 9px 15px;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.12s, border-color 0.12s;
}

button:hover { background: var(--bg-hover); }

button.primary {
  background: var(--accent-dim);
  border-color: var(--accent);
  font-weight: 600;
}

button.primary:hover { background: var(--accent); color: #04121f; }

.status {
  font-size: 13px;
  color: var(--text-dim);
  white-space: nowrap;
}

.count {
  color: var(--accent);
  font-weight: 600;
}

/* ------------------------------------------------------------------ grid */

/* Right-aligned like Sign out in the bar above, so the row has two ends
   rather than a ragged stop with empty space after it.
   Only where the row actually fits on one line: once it wraps, this leaves the
   button alone on its own row pushed to the right, which reads as a mistake
   rather than as alignment. */
@media (min-width: 900px) {
  #sync-steam { margin-left: auto; }
}

main {
  padding: 18px 20px 60px;
  max-width: calc(var(--page) + 40px);
  margin-inline: auto;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
  gap: var(--gap);
}

/* The visual card is the wrapper. The button inside it covers the artwork and
   title; the route picker sits beside the button rather than within it, because
   a <button> may not legally contain a <select> and browsers disagree about
   what happens when it does. */
.tile {
  position: relative;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  transition: transform 0.12s, border-color 0.12s;
}

/* Hover lifts and brightens, but does not go blue. Blue on a tile means "in
   your library" and nothing else — if hovering an unadded tile also turned it
   blue, the two states would be indistinguishable at a glance. */
.tile:hover {
  transform: translateY(-2px);
  border-color: rgba(255, 255, 255, 0.38);
}

.tile.added { border-color: var(--accent); }
.tile.added:hover { border-color: var(--accent); }

/* Vignette over the cover art, as on decky-alphastore's plugin banners.
 *
 * Two layers, and each does a different job. The radial darkens the corners so
 * a bright, busy cover stops competing with the title and the badge sitting on
 * top of it. The linear fades the bottom of the image into the card, so the
 * art meets the title block instead of ending on a hard edge.
 *
 * Drawn on .tile rather than on a wrapper around the image: .tile is already
 * positioned, and the art is the first thing in the card at aspect-ratio 1/1,
 * so anchoring to the top with the same ratio covers exactly the artwork and
 * nothing else. That avoids making .card positioned, which the badge and the
 * gate tag are anchored against.
 *
 * The fade colour is Shelfcloud's card background, not alphastore's — copying
 * its literal rgba(13,17,23) would fade this art towards a dark that is not
 * the colour underneath it, leaving a visible seam.
 */
.tile::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  aspect-ratio: 1 / 1;
  background:
    radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.5) 100%),
    linear-gradient(to bottom, transparent 50%, rgba(20, 27, 38, 0.7) 100%);
  pointer-events: none;   /* the whole card is a button; never eat a press */
  z-index: 1;
}

/* Above the vignette. Without this the overlay paints over them — it comes
   later in the box's paint order — and both sit inside the artwork area. */
.card .badge,
.card .gate-tag { z-index: 2; }

.card {
  position: static;
  background: none;
  border: 0;
  border-radius: 0;
  cursor: pointer;
  text-align: left;
  padding: 0;
  color: inherit;
  font: inherit;
  display: flex;
  flex-direction: column;
  width: 100%;
}

.card:hover { background: none; }

/* Controller navigation lives or dies on being able to see what is selected,
   so focus is deliberately louder than hover. Drawn inset because the tile
   clips overflow — an outward offset would be cut off.

   :focus-visible ONLY. Plain :focus also matches a tap, and a tapped button
   keeps focus afterwards, so adding a game left a second blue rectangle drawn
   around the artwork and title — inside the tile's own blue "added" border,
   with a sliver of card between the two. Three nested blue lines, none of
   which meant anything once the press was over.
   :focus-visible is the browser's own answer to "did they navigate here or
   point at it", so a controller and a keyboard still get the ring and a finger
   does not. */
.card:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: -3px;
  box-shadow: inset 0 0 0 6px rgba(77, 163, 255, 0.18);
}

.card .art {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  background: #0d131c;
  display: block;
}

.card .art.placeholder {
  display: grid;
  place-items: center;
  color: var(--text-dim);
  font-size: 28px;
}

.card .meta { padding: 9px 11px 11px; }

.card .name {
  font-size: 13.5px;
  font-weight: 560;
  line-height: 1.3;
  /* Three lines, reserved whether or not they are used, so every card is the
     same height and the grid stays even.
     Two lines cut "Ace Attorney Investigations Collection DEMO" down to
     "Ace Attorney Investigations…" — identical to the non-demo edition beside
     it, so the catalogue looked like it was listing one game twice. The word
     that distinguished them was the one being dropped.
     Three is not a guarantee; the details button is. This just stops the
     common case from needing it. */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: calc(3 * 1.3em);
  /* A single very long word cannot widen the card out of the grid. */
  overflow-wrap: anywhere;
}

.card .providers {
  margin-top: 6px;
  font-size: 11px;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.4px;
}

/* The add control, and the only thing on a card that says it is a button.
 *
 * Always visible, in both states. It used to fade in only once a game had been
 * added, so an untouched grid gave no clue that the artwork could be pressed —
 * and on a touch screen there is no hover state to discover it with.
 *
 * The label is CSS content keyed on .tile.added, so the optimistic class flip
 * in toggle() relabels it the instant it is pressed, with no second piece of
 * code to keep in step. The button's aria-label carries the same meaning for
 * anyone not seeing the pill.
 */
.card .badge {
  position: absolute;
  top: 8px;
  right: 8px;
  font-size: 11px;
  font-weight: 700;
  border-radius: 999px;
  padding: 4px 9px;
  /* Dark and translucent, with a light border: this sits over artwork that
     could be any colour, and needs to stay legible on a pale cover. */
  background: rgba(9, 13, 20, 0.78);
  border: 1px solid rgba(255, 255, 255, 0.22);
  color: var(--text);
  backdrop-filter: blur(2px);
  transition: background 0.12s, border-color 0.12s, color 0.12s;
}

.card .badge::after { content: "+ Add"; }

.tile.added .badge {
  background: var(--accent);
  border-color: var(--accent);
  color: #041427;
}
.tile.added .badge::after { content: "✓ Added"; }

/* Answer the pointer, so the pill reads as the thing being pressed. On an
   added game it turns into the removal it would actually perform, rather than
   leaving you to guess that pressing again undoes it.
   Outline, not fill: a filled blue pill already means "added", so hovering an
   unadded card must not produce the same thing. */
.card:hover .badge,
.card:focus-visible .badge {
  background: rgba(9, 13, 20, 0.85);
  border-color: var(--accent);
  color: var(--accent);
}
.tile.added .card:hover .badge,
.tile.added .card:focus-visible .badge {
  background: var(--danger);
  border-color: var(--danger);
  color: #1a0d0d;
}
.tile.added .card:hover .badge::after,
.tile.added .card:focus-visible .badge::after { content: "✕ Remove"; }

.empty, .more {
  text-align: center;
  color: var(--text-dim);
  padding: 34px 0;
}

.more button { padding: 10px 22px; }

/* ------------------------------------------------------------- login gate */

/* 14vh of top margin was right when this was a small sign-in card and centring
   it was the whole layout. It now carries the heading, the product description
   and the link into the catalogue — so a viewport-proportional gap spends the
   entire first screenful on a phone, which is the one screen that decides
   whether someone arriving from a search result reads any of it. A fixed,
   modest gap instead, and less again on small screens. */
/* The sign-in gate, sized for the screen it is actually on.
 *
 * This is the one page a Steam Machine user sees from a sofa. It was a 460px
 * card of 15px text: fine at a desk, a stamp in the middle of a television.
 * The fix is not a TV breakpoint but a card that scales — everything inside is
 * in `em`, so setting ONE font-size here moves the heading, the body, the
 * button, the note and the logo together and they keep their proportions.
 *
 * The clamp floor is today's 15px, so nothing below ~1250px viewport changes.
 * The ceiling stops a 4K desktop from rendering a poster.
 */
.gate {
  font-size: clamp(15px, 1.2vw, 28px);
  max-width: clamp(460px, 48vw, 1000px);
  margin: 40px auto;
  text-align: center;
  padding: 2.3em 1.9em;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: 16px;
}

@media (max-width: 560px) {
  .gate {
    margin: 14px auto 24px;
    padding: 1.6em 1.2em;
    border-radius: 12px;
  }
}

.gate h2 { margin: 0 0 0.45em; font-size: 1.47em; }
.gate p { color: var(--text-dim); margin: 0 0 1.4em; }

/* em, so the control grows with the card rather than staying a desk-sized
   button under television-sized text. */
.gate button {
  font-size: 1em;
  padding: 0.6em 1.1em;
  border-radius: 0.55em;
}

/* These were inline style attributes, which the Content-Security-Policy blocks.
   Keeping style-src strict is worth more than the convenience: the same policy
   is what stops injected script reaching the bearer token in localStorage. */
.dev-wrap { margin-top: 16px; }

.dev-warning {
  display: block;
  margin-top: 8px;
  font-size: 12px;
  color: var(--danger);
}

.gate-note { margin-top: 1.2em; font-size: 0.87em; }

/* -------------------------------------------------- sign-in result page */

.auth-result {
  min-height: 100vh;
  display: grid;
  place-items: center;
  text-align: center;
}

.auth-result .inner { padding: 40px 28px; max-width: 420px; }
.auth-result .mark { font-size: 44px; margin-bottom: 8px; }

/* The real mark on the auth pages. Sized in px rather than em: this is an
   image, not a glyph, and it should not inherit the font scaling that the
   surrounding text uses. */
.auth-result .mark-img {
  display: block;
  width: 56px;
  height: 56px;
  margin: 0 auto 10px;
}

.auth-result h1 {
  margin: 0 0 10px;
  font-size: 24px;
  /* The one surviving green: this confirms an action worked. It is a success
     message, not a piece of Shelfcloud's identity. */
  color: var(--ok);
}

.auth-result.failed h1 { color: var(--danger); }
.auth-result p { margin: 0; color: var(--text-dim); }

.hidden { display: none !important; }

/* One column that owns the corner, so messages queue instead of colliding.
   Each toast used to be fixed at the same bottom: 22px, so two at once landed
   in the same place and printed through one another. */
.toast-stack {
  position: fixed;
  z-index: 90;               /* over the grid; under .dd-menu at 100 */
  bottom: 22px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  /* The stack spans the screen so its children can centre, but must not eat
     clicks meant for the grid behind it. */
  width: max-content;
  max-width: min(92vw, 420px);
  pointer-events: none;
}

.toast {
  /* Bounded and centred, so a long message wraps inside the box rather than
     stretching the box to the length of the sentence. */
  max-width: 100%;
  text-align: center;
  background: var(--bg-hover);
  color: var(--text);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  padding: 11px 18px;
  font-size: 14px;
  line-height: 1.4;
  box-shadow: 0 8px 26px rgba(0, 0, 0, 0.5);
}

.toast.error { border-color: var(--danger); color: var(--danger); }

/* The route picker's rules live with the other dropdown rules, below.
 *
 * A block here used to style `.tile .storefront` as though it were still the
 * native <select> it once was. The picker has been a .dd since native selects
 * were replaced, so that block was styling the CONTAINER of a control rather
 * than a control: its width: calc(100% - 22px) survived the newer
 * `.tile .dd.storefront` rules — which are more specific, so they won on
 * margin and border but never mentioned width — and left the footer 22px
 * short on the right only, with the old border and dark background still
 * drawn around it. Two rule sets for one element, disagreeing.
 */

/* ------------------------------------------------------- services sheet */

.sheet {
  /* 520px was a phone measurement that never grew up. On a desktop the sheet
     holds shelves, devices and websites, each a row of a label and two or
     three buttons, and at 520 those rows wrap constantly while the screen
     around them is mostly empty. Still capped: it is a sheet, not a page. */
  width: min(720px, calc(100vw - 32px));
  padding: 0;
  border: 1px solid var(--edge);
  border-radius: 16px;
  background: var(--bg-raised);
  color: var(--text);
  /* Lift it off the grid behind: a flat panel on a flat page reads as part
     of the page rather than as something on top of it. */
  box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
}

.sheet::backdrop {
  background: rgba(4, 8, 14, 0.68);
  backdrop-filter: blur(3px);
}

.sheet-head {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 18px 20px 14px;
  border-bottom: 1px solid var(--edge);
}

.sheet-head h2 {
  margin: 0;
  font-size: 17px;
  font-weight: 650;
  margin-right: auto;
}

.sheet-head button {
  padding: 4px 10px;
  font-size: 15px;
  line-height: 1;
  color: var(--text-dim);
}

.sheet-lede {
  margin: 0;
  padding: 15px 20px 4px;
  font-size: 13px;
  color: var(--text-dim);
}

.service {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 15px 20px;
  border-bottom: 1px solid var(--edge);
}

.service:last-of-type { border-bottom: 0; }

.service-text { flex: 1; min-width: 0; }

.service-name {
  font-weight: 560;
  display: flex;
  align-items: baseline;
  gap: 8px;
}

.service-count {
  font-size: 11px;
  color: var(--text-dim);
  font-weight: 400;
}

.service-note {
  font-size: 12px;
  color: var(--text-dim);
  margin-top: 3px;
  line-height: 1.45;
}

.service-note a { color: var(--accent); }

/* A real switch. A bare checkbox reads as a form field; this reads as a
   power button for a service, which is what it is. */
.switch {
  position: relative;
  flex: 0 0 auto;
  width: 46px;
  height: 26px;
  border-radius: 999px;
  border: 1px solid var(--edge);
  background: var(--bg);
  cursor: pointer;
  padding: 0;
  transition: background 0.16s, border-color 0.16s;
}

.switch::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--text-dim);
  transition: transform 0.16s, background 0.16s;
}

.switch[aria-checked="true"] {
  background: var(--accent-dim);
  border-color: var(--accent);
}

.switch[aria-checked="true"]::after {
  transform: translateX(20px);
  background: #eaf4ff;
}

.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Laid out like the service rows above — label left, control right — so the
   whole sheet reads as one list of settings rather than two different shapes. */
.sheet-field {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 15px 20px;
  border-top: 1px solid var(--edge);
  font-size: 13px;
}

.sheet-field-text { flex: 1; min-width: 0; }

.sheet-field-text > span {
  display: block;
  font-weight: 560;
  font-size: 14.5px;
}

.sheet-field small {
  display: block;
  margin-top: 3px;
  color: var(--text-dim);
  font-size: 12px;
  line-height: 1.45;
}

/* Sized to its content and pinned right, but never allowed to crowd out the
   label on a narrow screen. */
.sheet-field .dd {
  flex: 0 0 auto;
  max-width: 48%;
}

.sheet-field .dd .dd-toggle { font-size: 13.5px; padding: 8px 28px 8px 11px; }

@media (max-width: 460px) {
  .sheet-field { flex-direction: column; align-items: stretch; gap: 9px; }
  .sheet-field .dd { max-width: none; }

  /* The danger row stacks for the same reason the others do: side by side on a
     phone leaves the explanation a narrow column beside the button, and this
     is the one row whose explanation must actually be read. */
  .sheet-danger { flex-direction: column; align-items: stretch; gap: 12px; }
  .sheet-danger button.danger { width: 100%; padding: 12px 14px; }
}

/* A game whose only route needs a service the user has not switched on. Kept
   visible and addable — it is a prompt, not an error. */
.tile.gated .art { opacity: 0.4; }
.tile.gated .name { color: var(--text-dim); }

.card .gate-tag,
.card .tier-tag {
  position: absolute;
  top: 8px;
  left: 8px;
  background: rgba(11, 15, 22, 0.9);
  border: 1px solid var(--accent);
  color: #cfe6ff;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.3px;
  text-transform: uppercase;
  border-radius: 12px;
  padding: 3px 8px;
  /* Room kept for the pill opposite.
     Both are absolutely positioned on the same line, so neither can push the
     other and max-width was the only thing holding them apart — and it was
     measured against the card, not against the pill. On a seven-column grid a
     174px card gives "Needs Premium" 106px and the pill 67px, which with the
     margins wants 189px: the tag ran 15px under the pill and buried it, and
     the pill is the control. 73px is the widest label ("✕ Remove", 67px) plus
     a 6px gap, measured rather than estimated. */
  max-width: calc(100% - 16px - 73px);
  /* Wraps instead of truncating. There is vertical room over the artwork and
     none across it, so two short lines keep the whole phrase where one line
     would have to say "Needs Prem…" — and on a handheld there is no hover to
     reveal the title attribute that carries the rest. */
  white-space: normal;
  overflow-wrap: anywhere;
  line-height: 1.25;
}

/* "Needs Premium" is not the same kind of statement as "switch this service
   on". The gate tag marks something the user fixes in this app with one
   toggle; this marks something that costs money elsewhere. Amber rather than
   the accent, so the two do not read as the same instruction — and amber
   rather than red, because the game is not broken, it is just not yours. */
.card .tier-tag {
  border-color: var(--warn);
  color: #ffd98a;
}

/* ------------------------------------------------------------- dropdown
   Replaces every native <select>: an OS popup cannot render inside Steam's
   in-client browser under Gamescope. Built from buttons so it lives in the
   page like anything else. */

.dd { position: relative; display: inline-block; }

.dd-toggle {
  width: 100%;
  text-align: left;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  color: var(--text);
  padding: 9px 30px 9px 11px;
  font-size: 14px;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The chevron is drawn rather than a glyph, so it never depends on a font. */
.dd-toggle::after {
  content: "";
  position: absolute;
  right: 12px;
  top: 50%;
  width: 6px;
  height: 6px;
  border-right: 2px solid var(--text-dim);
  border-bottom: 2px solid var(--text-dim);
  transform: translateY(-70%) rotate(45deg);
  pointer-events: none;
}

.dd-toggle:hover { background: var(--bg-hover); border-color: var(--accent); }
.dd-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* Fixed, and appended to <body>, so a card's overflow cannot clip it. */
.dd-menu {
  position: fixed;
  z-index: 100;
  max-height: 46vh;
  overflow-y: auto;
  background: var(--bg-raised);
  border: 1px solid var(--line-strong, var(--accent));
  border-radius: var(--radius);
  box-shadow: 0 12px 34px rgba(0, 0, 0, 0.6);
  padding: 4px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* 11px of vertical padding, not 9px, which puts a row at ~40px.
 *
 * This is a list you have to AIM at, and on a Deck the cursor is driven by a
 * thumbstick rather than a hand on a mouse — overshooting a row here picks the
 * wrong shelf or the wrong service. The toggle can stay compact because it is
 * a single large target; the rows cannot. */
.dd-item {
  background: none;
  border: 0;
  border-radius: 6px;
  color: var(--text);
  text-align: left;
  padding: 11px 12px;
  font-size: 14px;
  cursor: pointer;
  white-space: nowrap;
}

.dd-item:hover { background: var(--bg-hover); }

/* A finger is not a cursor. On the Deck's own browser these rows are tapped,
   and 40px is the size you miss — 48 is the smallest target that stops a tap
   landing on the neighbour. Coarse pointers only, so a mouse keeps the denser
   list. */
@media (pointer: coarse) {
  .dd-item { padding: 15px 14px; }
  .dd-toggle { padding-top: 12px; padding-bottom: 12px; }
  .dd-menu { padding: 6px; }
}

/* Focus must be loud: on a Deck this is driven by a thumbstick, not a cursor. */
.dd-item:focus,
.dd-item:focus-visible {
  outline: none;
  background: var(--accent-dim);
  box-shadow: inset 0 0 0 2px var(--accent);
}

.dd-item.selected { color: var(--accent); font-weight: 600; }

/* Header filters sit in a flex row and should size like the old selects. */
.dd-inline { flex: 0 0 auto; }

/* The route picker on a card.
 *
 * It has to live OUTSIDE .card — a <button> may not contain another control —
 * and without help that is exactly how it read: a separate widget that
 * happened to sit nearby. Inset 11px a side, on a background DARKER than the
 * tile, floating in the gap below the title, it looked like a hole punched
 * under the card rather than part of it. The card's focus ring, which can
 * only wrap the button, drew a hard line between the two and finished the job.
 *
 * Drawn instead as the card's footer row: full bleed, sharing the tile's own
 * surface, with one hairline above it. The hairline is what makes it a part
 * rather than a neighbour — the same device a table uses for a total.
 */
/* The footer stacks its rows rather than sitting them side by side. A tile is
   ~180px wide on a phone; two controls sharing that line would be 90px each,
   which fits neither "Shared — every device" nor a service name. */
.tile-footer {
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--edge);
}

.tile-footer > .dd + .dd { border-top: 1px solid var(--edge); }

.tile .dd.storefront,
.tile .dd.shelves {
  display: block;
  margin: 0;
  min-width: 0;
}

.tile .dd.storefront .dd-toggle,
.tile .dd.shelves .dd-toggle {
  /* These are VALUES, not captions: one names the service that will launch,
     the other where the game lives. At --text-dim, 11px, on the darkest
     surface on the page and directly under a bright white title, the route
     picker read as disabled — the one thing it is not, being the only control
     on the card. It clears AA either way (6.6:1), so this is about what the
     colour SAYS, not about the ratio. */
  font-size: 12px;
  padding: 8px 26px 8px 11px;    /* 11px left: lines up with the title above */
  border: 0;
  border-radius: 0;
  background: none;
  color: var(--text);
}

.tile .dd.storefront .dd-toggle { text-transform: capitalize; }

.tile .dd.storefront .dd-toggle::after,
.tile .dd.shelves .dd-toggle::after { right: 10px; width: 5px; height: 5px; }

/* The whole row lights up, so the hit target and the affordance agree. */
.tile .dd.storefront .dd-toggle:hover,
.tile .dd.shelves .dd-toggle:hover { background: var(--bg-hover); }

/* ------------------------------------------------------- checkbox menus
   A dd-menu whose rows are ticked rather than chosen. */

.dd-item.dd-check {
  display: flex;
  align-items: center;
  gap: 9px;
}

/* The box is always drawn, ticked or not, so a label never shifts sideways as
   rows are toggled — movement under the cursor is how you mis-click the row
   below, and on a Deck the cursor is a thumbstick. */
.dd-mark {
  flex: 0 0 auto;
  width: 15px;
  height: 15px;
  border: 1px solid var(--text-dim);
  border-radius: 4px;
  position: relative;
}

.dd-item.on .dd-mark {
  background: var(--accent);
  border-color: var(--accent);
}

/* Drawn, not a glyph: a checkmark character depends on a font the Steam
   browser may not have, and renders as a box when it does not. */
.dd-item.on .dd-mark::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid #0b0f16;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

/* Inset, because .tile clips overflow: an outward offset on the bottom row
   would be cut off exactly where it matters on a Deck. */
.tile .dd.storefront .dd-toggle:focus-visible,
.tile .dd.shelves .dd-toggle:focus-visible { outline-offset: -2px; }

/* -------------------------------------------------------------- danger zone
 *
 * Set apart from the settings above it by a rule and its own colour, because
 * it is the only control in the sheet that destroys anything.
 */
.sheet-danger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* The same 20px sides as every other row. The sheet itself is padding: 0 —
     each row supplies its own — so a rule with only vertical padding puts its
     text hard against the dialog edge while everything above it lines up 20px
     in. Seen on a phone, where the text also clipped. */
  padding: 18px 20px 20px;
  gap: 16px;
  border-top: 1px solid var(--edge);
}

.sheet-danger .sheet-field-text span { color: var(--danger); }

button.danger {
  flex: 0 0 auto;
  background: transparent;
  color: var(--danger);
  border: 1px solid var(--danger);
  border-radius: var(--radius);
  padding: 8px 14px;
  cursor: pointer;
}
button.danger:hover { background: rgba(255, 107, 107, 0.12); }

/* Armed: filled rather than outlined, so the second press is visibly a
   different act from the first. */
button.danger.armed {
  background: var(--danger);
  color: #1a0d0d;
  font-weight: 700;
}
button.danger:disabled { opacity: 0.6; cursor: default; }

/* ---------------------------------------------------------------- documents
 *
 * The privacy page, and any prose page after it. Kept here rather than in a
 * <style> block on the page itself: the Content-Security-Policy has no
 * 'unsafe-inline', so an inline block is silently dropped and the page renders
 * unstyled. That already happened once, to the sign-in page.
 *
 * A reading measure rather than the app's full width — the catalogue is a grid
 * to be scanned, this is text to be read, and 68ch is where prose stops being
 * tiring.
 */
.doc-body {
  background: var(--bg);
  color: var(--text);
  margin: 0;
  padding: 0 20px 80px;
}

.doc {
  /* One width on every page — the guide, the privacy notice, a game and the
     index all sit in the same frame, so moving between them does not shift the
     page under you. It used to be a reading measure, which was right for the
     prose pages and left a game's cover and details stacked in a column half
     the window wide. 960px is the middle ground: a game's cover sits beside
     its details, the index holds five or six covers a row, and the page still
     reads as a page rather than as edge-to-edge furniture. */
  max-width: 960px;
  margin: 0 auto;
  padding-top: 28px;
  line-height: 1.65;
}

.doc-nav { margin-bottom: 28px; }
.doc-nav a {
  color: var(--text-dim);
  text-decoration: none;
  font-size: 0.9rem;
}
.doc-nav a:hover { color: var(--accent); }

.doc h1 {
  font-size: 2rem;
  margin: 0 0 10px;
  letter-spacing: -0.01em;
}

.doc h2 {
  font-size: 1.15rem;
  margin: 40px 0 12px;
  padding-bottom: 8px;
  border-bottom: 1px solid var(--edge);
}

.doc-lede {
  color: var(--text-dim);
  font-size: 1.02rem;
  margin: 0 0 8px;
}

.doc-list { padding-left: 22px; margin: 12px 0; }
.doc-list li { margin-bottom: 10px; }

.doc-table-wrap { overflow-x: auto; }

.doc-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.94rem;
  margin: 14px 0;
}
.doc-table th {
  text-align: left;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-dim);
  border-bottom: 1px solid var(--edge);
  padding: 8px 12px 8px 0;
}
.doc-table td {
  vertical-align: top;
  padding: 12px 12px 12px 0;
  border-bottom: 1px solid var(--edge);
}
.doc-table td:last-child { white-space: nowrap; color: var(--text-dim); }
.doc-table .doc-em { color: var(--accent); font-weight: 600; }

.doc-defs { margin: 14px 0; }
.doc-defs dt {
  font-weight: 600;
  color: var(--accent);
  margin-top: 16px;
}
.doc-defs dd { margin: 4px 0 0; color: var(--text); }

.doc-code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.88em;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: 4px;
  padding: 1px 5px;
}

.doc-note {
  background: var(--bg-raised);
  border-left: 3px solid var(--accent);
  border-radius: 0 var(--radius) var(--radius) 0;
  padding: 12px 16px;
  margin: 20px 0;
}

.doc-contact {
  margin-top: 34px;
  padding-top: 18px;
  border-top: 1px solid var(--edge);
}
.doc-dim { color: var(--text-dim); }

.doc-updated {
  color: var(--text-dim);
  font-size: 0.85rem;
  margin-top: 6px;
}

/* The link that reaches the privacy page from the sign-in gate. No rule: the
   gate is a narrow centred card, and a full-width line across it reads as a
   divider between things rather than as a footer. */
.site-foot {
  text-align: center;
  padding: 18px 0 0;
  color: var(--text-dim);
  font-size: 0.85rem;
}
.site-foot a { color: var(--text-dim); }
.site-foot a:hover { color: var(--accent); }

/* The catalogue's page footer: a rule, then the copyright and the privacy
   link. The rule closes the grid off — without it the last row of cards and
   the footer text run together. */
.page-foot {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: 34px;
  padding: 18px 0 40px;
  border-top: 1px solid var(--edge);
  color: var(--text-dim);
  font-size: 0.85rem;
}
.page-foot a { color: var(--text-dim); text-decoration: none; }
.page-foot a:hover { color: var(--accent); text-decoration: underline; }
.foot-sep { opacity: 0.5; }

/* ------------------------------------------------------- header on a phone
 *
 * The header is a wrapping flex row, which on a wide screen puts everything on
 * two tidy lines. On a phone it wrapped into five ragged ones, each half
 * empty: Services and Sign out took 185px of a 372px row, the two filters
 * 224px, and "Sync Steam library" fell onto a line of its own looking like it
 * had been forgotten there. Measured at 412px, which is the reported device.
 *
 * The order is unchanged — Services, Sign out, search, filters, sync. What
 * changes is that each row is deliberately filled, so the ragged edge reads as
 * a layout rather than as wrapping that got away.
 */
@media (max-width: 560px) {
  header { padding: 12px 14px 10px; }

  .bar { gap: 10px; }

  /* Brand and count keep the first row to themselves. Without pinning the
     basis, "Services" tried to squeeze onto that row and was clipped by the
     viewport edge — flex will shrink an item past its content before it will
     wrap it. */
  .brand { margin-right: auto; min-width: 0; }
  .brand span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }

  /* Half the row each, which is wide enough that neither can fit beside the
     brand, so both wrap together onto a row of their own. */
  .bar > button {
    flex: 1 1 calc(50% - 5px);
    min-width: 0;
  }

  .controls { gap: 8px; margin-top: 10px; }

  /* Search gets its own full-width line: it is the control people came for,
     and a partial-width search box on a phone is hard to aim at. */
  .controls > #search { flex: 1 1 100%; }

  /* The two filters split one line between them. */
  .controls > .dd {
    flex: 1 1 calc(50% - 4px);
    min-width: 0;
  }
  .controls > .dd .dd-toggle { width: 100%; }

  /* Full width, so being on its own line looks chosen rather than orphaned. */
  .controls > #sync-steam { flex: 1 1 100%; }
}


/* ---------------------------------------------------------------- SEO pages
   The crawlable game pages and the homepage's readable section. Plain,
   text-first layout: these are read by people arriving from a search result
   with no context, so the job is legibility, not decoration. */

.gate-about { margin-top: 1.85em; text-align: left; border-top: 1px solid var(--edge); padding-top: 1.45em; }
.gate-about h1 { font-size: 1.27em; margin: 0 0 0.5em; }
.gate-about p { color: var(--text-dim); margin: 0 0 0.8em; font-size: 0.93em; line-height: 1.6; }

.game-body { display: flex; gap: 26px; align-items: flex-start; flex-wrap: wrap; margin-top: 20px; }
.game-art { width: 240px; height: auto; border-radius: 10px; flex: none; }
.game-detail { flex: 1 1 320px; min-width: 260px; }
.game-detail h2 { font-size: 16px; margin: 0 0 10px; }

.provider-list { list-style: none; padding: 0; margin: 0 0 20px; }
.provider-row { padding: 9px 13px; background: var(--bg-raised); border: 1px solid var(--edge);
                border-radius: 8px; margin-bottom: 7px; }

.game-facts { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 0 0 20px;
              font-size: 14px; }
.game-facts dt { color: var(--text-dim); }
.game-facts dd { margin: 0; }

.game-cta { margin: 0 0 14px; }
.game-note { color: var(--text-dim); font-size: 13px; line-height: 1.6; margin: 0; }

/* A wide frame does not mean wide sentences.
   The text-flow children keep a reading measure, while the things that want
   the room take it: the cover grid, the tables, a game's art-and-detail row.
   Capped on the children rather than on the container because those are two
   different needs and a container can only serve one of them. */
.doc > p,
.doc > h1,
.doc > h2,
.doc > h3,
.doc > ul:not(.game-index),
.doc > ol,
.doc > dl,
.doc > blockquote { max-width: 68ch; }

/* Three exceptions, each because the element is a RULE as much as it is text.
   The pager centres itself across the page it pages through; the footer and
   the disclaimer carry a border-top that reads as broken when it stops
   two-thirds of the way across the frame. */
.doc > .pager,
.doc > .page-foot,
.doc > .disclaimer { max-width: none; }

/* Prose inside the game's detail column, which is a flex child and so escapes
   the child cap above. Without this the closing note runs the better part of a
   thousand pixels on one line. */
.game-note,
.game-detail > p { max-width: 68ch; }

/* The index, as a wall of covers rather than a list of names.
   It is still what it was — a linked, crawlable list of every game, which is
   the whole reason the page exists — but a reader browsing 3,300 titles
   recognises a cover long before they read a name. */
.game-index {
  list-style: none;
  padding: 0;
  margin: 20px 0;
  display: grid;
  /* Same shape as the app's grid so the two do not read as different products.
     auto-fill rather than auto-fit: a short last row should stay card-sized
     instead of stretching two covers across the whole width. */
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 18px 14px;
}

.game-index a {
  display: flex;
  flex-direction: column;
  gap: 6px;
  color: inherit;
  text-decoration: none;
}

.idx-art {
  position: relative;
  display: block;
  /* 600x900 is the capsule Steam uses for a grid tile. Declared here as well
     as on the <img> so a cover that has not loaded still holds its space and
     the grid does not reflow 200 times on the way down the page. */
  aspect-ratio: 2 / 3;
  border-radius: 10px;
  overflow: hidden;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
}

.idx-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 160ms ease;
}

/* The vignette, the same two layers as the app's tiles.
   The radial darkens the corners so a bright, busy cover stops competing with
   the title under it; the linear fades the foot of the art into the page so it
   meets the name instead of ending on a hard edge. */
.idx-art::after {
  content: "";
  position: absolute;
  inset: 0;
  background:
    radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.5) 100%),
    linear-gradient(to bottom, transparent 55%, rgba(11, 15, 22, 0.75) 100%);
  pointer-events: none;
}

/* 78 of 3,336 games have no art at all. A first letter on the card background
   is a better answer than a broken-image icon or an empty hole. */
.idx-blank {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 40px;
  font-weight: 700;
  color: var(--edge);
  background: var(--bg-raised);
}

.idx-name {
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  color: var(--text);
}

.idx-providers {
  color: var(--text-dim);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.3px;
  line-height: 1.3;
}

.game-index a:hover .idx-art,
.game-index a:focus-visible .idx-art { border-color: var(--accent); }
.game-index a:hover .idx-img { transform: scale(1.04); }
.game-index a:hover .idx-name { color: var(--accent); }
.game-index a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }

@media (prefers-reduced-motion: reduce) {
  .idx-img { transition: none; }
  .game-index a:hover .idx-img { transform: none; }
}

.pager { display: flex; gap: 18px; justify-content: center; margin: 24px 0; }

@media (max-width: 560px) {
  .game-art { width: 100%; max-width: 280px; margin: 0 auto; }
  /* Narrower cards on a phone, so a row still holds more than one. */
  .game-index { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 14px 10px; }
}

/* The trademark disclaimer. Present on every page, but it is legal boilerplate
   rather than something anyone came here to read: small, dim, and last. */
.disclaimer {
  margin: 26px 0 0;
  padding-top: 16px;
  border-top: 1px solid var(--edge);
  color: var(--text-dim);
  font-size: 11.5px;
  line-height: 1.6;
  text-align: left;
}

/* ------------------------------------------------------------- shelves */

/* One field, not a label standing next to a box.
 *
 * As two separate things it read badly on a phone: "Shelf" stranded against
 * the viewport edge, and beside it a control running the full width of the
 * screen with its text at one end, its chevron at the other and nothing in
 * between. Drawn as a single bordered field with the label as a prefix inside
 * it, the two read as one control and the empty middle disappears.
 *
 * The border lives here rather than on the toggle, so the label sits inside
 * the same box as the value.
 */
.shelf-switch {
  display: flex;
  align-items: center;
  min-width: 0;
  max-width: 260px;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
}

.shelf-switch:hover { border-color: var(--accent); }

.shelf-switch-label {
  flex: 0 0 auto;
  padding-left: 11px;
  font-size: 12px;
  color: var(--text-dim);
}

/* min-width: 0 is what lets the toggle's ellipsis work: without it a flex item
   refuses to shrink below its content, and a long shelf name stretches the
   header instead of being shortened. */
.shelf-switch .dd { flex: 1 1 auto; min-width: 0; }

.shelf-switch .dd-toggle {
  width: 100%;
  border: 0;
  background: none;
  padding-left: 8px;
}

/* The field owns the hover, so the two halves light up together. */
.shelf-switch .dd-toggle:hover { background: none; }

/* Inset: the field clips at its own rounded border, so an outward offset is
   cut off. */
.shelf-switch .dd-toggle:focus-visible { outline-offset: -2px; }

/* The same 20px sides as every other row in the sheet. .sheet is padding: 0 —
   each row supplies its own — so a rule with only vertical padding puts its
   content hard against the edge, which is what happened here: the "Add shelf"
   button was clipped by the dialog border. The same note sits on .sheet-danger,
   which is the trap this repeated. */
.sheet-section {
  border-top: 1px solid var(--edge);
  padding: 16px 20px;
}

.sheet-empty { color: var(--text-dim); font-size: 13px; margin: 10px 0 0; }

.shelf-row {
  display: flex;
  /* Top, not centre. The text beside it is two lines — a name and a count —
     so centring floats the button between them while the device list next to
     it puts Rename on the name's line. Two lists in one sheet should line up
     the same way. */
  align-items: flex-start;
  gap: 14px;
  /* Sides come from .sheet-section; the 12px is the same one-step indent the
     device rows use, so a shelf name does not start on the same column as the
     "Shelves" heading above it. */
  padding: 10px 0 10px 12px;
  border-bottom: 1px solid var(--edge);
}
.shelf-row:last-child { border-bottom: 0; }
.shelf-row .sheet-field-text { flex: 1; min-width: 0; }

/* The globe and the name share a line. min-width on the line rather than on
   the name so the rename editor, which replaces the name in here, can shrink
   instead of pushing the icon out of the row. */
/* The icon, then a column holding the name and the address. align-items at
   the start rather than centre: the column is two lines, and centring floats
   the globe between them instead of beside the name it labels.
   Qualified by the parent for the same reason .device-rename-edit is, a few
   hundred lines below: `.sheet-field-text > span` sets display: block, and a
   type+class selector beats a bare class — so a plain `.website-head` rule
   loses and the globe drops onto its own line. It did exactly that. */
.sheet-field-text > .website-head {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  min-width: 0;
}
.website-text { display: flex; flex-direction: column; min-width: 0; }
/* The name is no longer a direct child of .sheet-field-text, so it fell out
   of the rule that gives every name in this sheet its weight and size. */
.website-text > span { display: block; font-weight: 560; font-size: 14.5px; }
.website-icon {
  flex: none;
  display: inline-flex;
  color: var(--text-dim);
  /* Optically on the name's line: the name's line-height leaves the 15px glyph
     sitting high against a cap-height it does not share. */
  margin-top: 2px;
}

.shelf-add { display: flex; gap: 8px; margin-top: 12px; }

/* A disabled control has to LOOK unavailable, or the only sign the cap exists
   is a press that does nothing. */
button:disabled,
input:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

button:disabled:hover { background: var(--bg-raised); border-color: var(--edge); }

#shelf-count { margin-top: 8px; }
.shelf-add input {
  flex: 1;
  min-width: 0;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  color: var(--text);
  padding: 9px 12px;
  font-size: 14px;
}

/* Two fields and a button. On a phone they stack: a name box and an address
   box side by side leave neither wide enough to read what was typed, and the
   address is the one thing here people check before pressing. */
.website-add { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.website-add input {
  flex: 1 1 100%;
  min-width: 0;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  color: var(--text);
  padding: 9px 12px;
  font-size: 14px;
}
.website-add button { flex: 0 0 auto; margin-left: auto; }

/* Wide enough for all three on one line. nowrap rather than generous widths:
   with wrap on, the two fields take the whole row and the button drops to a
   line of its own even when there is clearly room beside them. */
@media (min-width: 560px) {
  .website-add { flex-wrap: nowrap; }
  .website-add #website-name { flex: 0 1 10rem; }
  .website-add #website-url { flex: 1 1 9rem; }
  .website-add button { margin-left: 0; }
}

#website-count { margin-top: 8px; }

/* An address has no spaces to wrap at, and this row must not widen the sheet.
   The rest restates what `.sheet-field small` gives every other second line in
   this sheet: nesting the address inside .website-text took it out of that
   rule's reach, and it also picked up the 560 weight that `.sheet-field-text >
   span` now sets on the line above it. Left alone it read as a second title
   rather than as the quiet detail under one. */
.website-url {
  overflow-wrap: anywhere;
  margin-top: 3px;
  color: var(--text-dim);
  font-size: 12px;
  font-weight: 400;
  line-height: 1.45;
}

.device-here { color: var(--accent); font-style: normal; font-weight: 400; }

/* Rename sits between the name and the shelf picker, and must not grow: the
   name has the flexible width, and a button that stretches with it reads as
   the primary action on the row, which it is not. */
.device-rename {
  flex: 0 0 auto;
  /* Below 460px .sheet-field becomes a column with align-items: stretch, and a
     stretched Rename spans the row and reads as its primary action. It is the
     secondary one — the shelf picker is why anyone opens this list. */
  align-self: flex-start;
  font-size: 12px;
  padding: 6px 11px;
}

.device-rename:disabled { opacity: 0.5; cursor: default; }

/* Replaces the name in place, so the row does not jump when editing starts.
   Wraps on a narrow screen rather than squeezing the field to nothing. */
.device-rename-edit {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}

.device-rename-input {
  /* A small basis so the field, Save and Cancel share one line on a phone;
     it still takes every spare pixel, because it grows. */
  flex: 1 1 96px;
  min-width: 0;
  background: var(--bg);
  border: 1px solid var(--accent);
  border-radius: 6px;
  color: var(--text);
  padding: 6px 9px;
  font-size: 13px;
  font-family: inherit;
}

/* Save and Cancel exist because saving must not depend on focus: Steam's
   on-screen keyboard is an overlay that takes focus from the page, so a
   save-on-blur rename ended the moment the keyboard opened. */
.device-rename-save,
.device-rename-cancel {
  flex: 0 0 auto;
  font-size: 12px;
  padding: 6px 11px;
}

.device-rename-save {
  background: var(--accent-dim);
  border-color: var(--accent);
  font-weight: 600;
}

.device-rename-save:hover { background: var(--accent); color: #04121f; }

/* Tapped with a thumb on a Deck, and confirming a rename is not a control to
   miss. Same reasoning as the dropdown rows. */
@media (pointer: coarse) {
  .device-rename,
  .device-rename-save,
  .device-rename-cancel { padding: 12px 16px; font-size: 13px; }
}

.device-rename-input:focus { outline: 2px solid var(--accent); outline-offset: -2px; }

/* .sheet-field carries 20px sides for rows that sit directly in the sheet.
   Inside .sheet-section those 20px are already applied, so keeping them would
   indent the device rows twice as far as the service rows above them.

   Not zero, though — 12px. At zero a device name starts on the same column as
   "Your devices" above it, so a list of machines reads as a run of headings
   and nothing says which belongs to what. One step in is enough to make them
   items; a second full 20px is the double indent this rule exists to stop. */
#device-list .sheet-field { padding-left: 12px; padding-right: 0; }
#device-list .sheet-field:first-child { border-top: 0; }

/* A device row is two lines at every width: the name with its button, then the
   shelf picker. Not the responsive column .sheet-field falls into, because
   that pushed Rename hard left under the name while the shelf list in the same
   sheet kept Delete on the right — one sheet, two lists, laid out differently
   for no visible reason. */
#device-list .sheet-field {
  flex-direction: column;
  align-items: stretch;
  gap: 9px;
}

.device-head {
  display: flex;
  align-items: flex-start;
  gap: 12px;
}

/* The name takes the room; the button takes what it needs and sits right. */
.device-head .sheet-field-text { flex: 1; min-width: 0; }
.device-head .device-rename { flex: 0 0 auto; margin-left: auto; }

/* Full width, because a shelf name plus a chevron in half a row is a truncated
   label on a phone. */
#device-list .sheet-field > .dd { max-width: none; width: 100%; }
#device-list .sheet-field > .dd .dd-toggle { width: 100%; }

@media (max-width: 560px) {
  /* My Shelfcloud and Sign out take the row directly under the brand; the shelf
     field drops below them, where it sits next to the search and filters it
     belongs with. Done with `order` rather than by moving the markup, so the
     reading order on a wide screen — where the bar is a single row — still
     puts the shelf before the account buttons. */
  .bar > button { order: 1; }
  .shelf-switch { order: 2; width: 100%; max-width: none; }
  .shelf-row { flex-wrap: wrap; }
}


/* ---------------------------------------------------------------- guide */
/* Screenshots come off a Deck at 1095px wide or more, so they are capped
   rather than scaled up, and centred when narrower than the column. */
.guide-shot {
  margin: 18px 0;
}
.guide-shot img {
  display: block;
  max-width: 100%;
  height: auto;
  margin: 0 auto;
  border: 1px solid var(--line, #253044);
  border-radius: 10px;
  background: #0b0f16;
}
.guide-shot figcaption {
  margin-top: 8px;
  font-size: 0.85rem;
  opacity: 0.7;
  text-align: center;
}
/* Numbered steps need room for the picture that belongs to each one. */
.guide-steps > li {
  margin-bottom: 18px;
}
.guide-warn {
  color: #f2b34b;
  font-weight: 600;
}
/* A flat row of anchors; wraps on a phone instead of scrolling sideways. */
.guide-toc {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 14px;
  margin: 20px 0 28px;
  padding: 14px 16px;
  border: 1px solid var(--line, #253044);
  border-radius: 12px;
  font-size: 0.9rem;
}


/* --------------------------------------------------------- game details */
/* Sits over the artwork, opposite the add pill so the two never collide.
   Large enough to hit with a thumb: 32px is the smallest comfortable target
   on the Deck's touch screen, and this is aimed at exactly that audience. */
.info-button {
  position: absolute;
  /* Under the add pill, not opposite it: top-left is where the gate and tier
     tags live, and "NEEDS PREMIUM" is exactly the card that most wants a
     details button. Stacking on the right keeps one column of controls and
     cannot collide with a tag of any length. */
  right: 8px;
  top: 38px;
  z-index: 2;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.28);
  background: rgba(8, 12, 20, 0.72);
  color: #e8eef8;
  /* Flex, and padding cleared: a <button> keeps the browser's own padding, and
     without centring the mark sat low as well as right of centre. */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  line-height: 0;
  cursor: pointer;
  backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity 120ms ease, background 120ms ease;
}
/* Revealed on hover for a pointer, and ALWAYS present for touch and keyboard —
   where hover does not exist and an invisible control is no control at all. */
.tile:hover .info-button,
.info-button:focus-visible {
  opacity: 1;
}
@media (hover: none) {
  .info-button { opacity: 1; }
}
.info-button:hover {
  background: rgba(8, 12, 20, 0.92);
}

/* .sheet sets padding: 0 and every section supplies its own — .sheet-head has
   18px 20px 14px. This one had none, so the facts sat hard against the left
   edge and the cover was flush under the divider. */
#info-body {
  padding: 20px 20px 22px;
}

/* The frame exists to carry the vignette: an <img> cannot have a ::after of
   its own, being a replaced element. */
.info-art-frame {
  position: relative;
  width: 100%;
  max-width: 260px;
  margin: 0 auto 20px;
  border-radius: 10px;
  overflow: hidden;
  line-height: 0;          /* no inline-box gap under the image */
}

/* The same treatment a card gets from .tile::after. A cover can be any colour
   and some are close to white; without this a bright one glares out of a dark
   panel and takes the eye off the text the dialog exists to show. */
.info-art-frame::after {
  content: "";
  position: absolute;
  inset: 0;
  background:
    radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.5) 100%),
    linear-gradient(to bottom, transparent 50%, rgba(20, 27, 38, 0.7) 100%);
  pointer-events: none;
}

.info-art {
  display: block;
  width: 100%;
  height: auto;
}
.info-facts {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 8px 18px;
  margin: 0;
  font-size: 14px;
}
.info-facts dt {
  color: var(--text-dim);
  white-space: nowrap;
}
.info-facts dd {
  margin: 0;
}


/* ------------------------------------------- pills sitting on the artwork */
/*
 * The add pill (right) and the gate/tier tag (left) are separate elements that
 * were sized independently — 11px text with 4px padding against 10px text with
 * 3px. They share a `top`, so their TOP edges matched and nothing else did:
 * different heights, different text centres, visibly out against each other.
 *
 * One box model for all three. A fixed height with the label centred means the
 * two sides line up exactly, whatever the text says and however long it is.
 * Declared last so it wins over the individual rules above without having to
 * unpick them.
 */
.card .badge,
.card .gate-tag,
.card .tier-tag {
  top: 8px;
  height: 22px;
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  padding: 0 9px;
  line-height: 1;
}

/* .badge and .gate-tag were lifted above the vignette; .tier-tag was not, so
   the overlay painted over it — it comes later in the box's paint order. */
.card .tier-tag { z-index: 2; }


/* Rename and Remove sit together on the device row; keep them from crowding
   the name when it is long. */
.device-remove { margin-left: 6px; }


/* ------------------------------------------------- header on a small screen */
/*
 * On a phone the header wraps into five rows - brand, account buttons, shelf,
 * search, the two filters, sync - and being sticky it kept all of them pinned.
 * That left roughly half the viewport permanently spent on controls, so the
 * catalogue scrolled through a letterbox.
 *
 * It hides as you scroll DOWN and comes back the moment you scroll UP, so the
 * grid gets the whole screen while reading and the search is one flick away
 * rather than a scroll back to the top. Unsticking it entirely would have been
 * simpler and would have cost exactly that.
 *
 * Only below 720px. On a desktop the header is one row and pinning it costs
 * nothing.
 */
@media (max-width: 720px), (max-height: 560px) {
  header {
    /* Collapsed by clipping rather than moved out of the way, so the top line
       — the mark, the name, and how many games are in the library — stays
       where it is. That line is the one piece of the header worth the space
       it costs: it says which account is looking and what the count is doing
       while you scroll, and the rest is controls you are not using yet.

       Both heights are measured by app.js and set on :root, because CSS
       cannot know how many rows this wrapping header took on this screen.
       Without the script the variables are unset, max-height is none, and the
       header is simply a whole header. */
    overflow: hidden;
    max-height: var(--header-full-h, none);
    transition: max-height 200ms ease;
  }
  header.header-tucked {
    max-height: var(--header-strip-h, none);
  }
}

/* Someone who has asked for less motion still gets to see what happened.
   The distinction the setting is really about is decoration that loops or
   sweeps versus feedback that something changed: a header shrinking by 180px
   with no transition reads as a glitch, and a fifth of a second of it is not
   what anyone means by "reduce motion". Shortened rather than removed. The
   looping glow on the mark IS decoration, and stays off — see mark-glow. */
@media (prefers-reduced-motion: reduce) {
  header { transition: max-height 120ms ease; }
}


/* The way back to a tucked header.
 *
 * A small tab at the top edge, present only while the header is away. It is
 * how the controls come back now that an upward scroll no longer does it —
 * small upward corrections while reading are constant, and letting each one
 * throw the header over the page is what made the earlier version irritating.
 *
 * Centred horizontally so it is reachable with either thumb, and clear of the
 * status bar on a phone with a notch.
 */
.header-reveal {
  position: fixed;
  /* Below the strip that stays, not at the top edge: the identity line is on
     screen now, and a tab over it would cover the thing it is pinned for. */
  top: calc(env(safe-area-inset-top, 0px) + var(--header-strip-h, 0px));
  left: 50%;
  transform: translateX(-50%) translateY(-120%);
  /* Under the header, which is 10, so hiding it slides it behind the strip
     rather than across it. */
  z-index: 9;
  display: none;               /* desktop: the header is one row and stays */
  align-items: center;
  justify-content: center;
  width: 62px;
  height: 26px;
  padding: 0;
  border: 1px solid var(--edge);
  border-top: 0;
  border-radius: 0 0 13px 13px;
  background: rgba(11, 15, 22, 0.94);
  color: var(--text-dim);
  cursor: pointer;
  backdrop-filter: blur(6px);
  transition: transform 180ms ease, color 120ms ease;
}

/* Same two conditions as the header itself, or the way back is missing on a
   phone held sideways. */
@media (max-width: 720px), (max-height: 560px) {
  .header-reveal { display: flex; }
  .header-reveal.is-visible { transform: translateX(-50%) translateY(0); }
}

.header-reveal:hover,
.header-reveal:focus-visible {
  color: var(--text);
}

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


/* How recently a device was heard from. */
.device-dot {
  display: inline-block;
  width: 9px;
  height: 9px;
  margin-right: 8px;
  border-radius: 50%;
  vertical-align: middle;
  /* A ring rather than a bare dot: on a dark panel a 9px circle of colour can
     read as a rendering artefact, and the ring gives it an edge at any size. */
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
  flex: none;
}
.device-dot.is-fresh   { background: #3fb950; }   /* under 3 hours */
.device-dot.is-idle    { background: #d29922; }   /* today, but not just now */
.device-dot.is-stale   { background: #f85149; }   /* over a day */
.device-dot.is-unknown { background: #6e7681; }   /* never used */


/* A rename takes the row it is on.
 *
 * The editor is a flex row of input, Save and Cancel. On a device row it had
 * space; on a shelf row it was competing with Rename AND Delete, so Save and
 * Cancel each wrapped onto a line of their own and the row fell apart.
 *
 * The other controls step aside instead. Nothing is lost: Rename is already
 * disabled while editing, and Delete during a rename is a mis-tap waiting to
 * happen — it takes the shelf's games with it. */
.is-renaming .device-rename,
.is-renaming .danger {
  display: none;
}
.is-renaming .sheet-field-text {
  flex: 1 1 100%;
  min-width: 0;
}
/* With the row to itself the editor no longer needs to wrap, so Save and
   Cancel stay on the input's line and the input takes what is left. */
.is-renaming .device-rename-edit {
  flex-wrap: nowrap;
  width: 100%;
}


/* The rename editor is a <span>, and .sheet-field-text > span sets
   display: block — a type+class selector, so it beat .device-rename-edit's
   display: flex and the editor was never a flex container at all. That is why
   Save and Cancel wrapped however the flex properties were tuned.
   Specific enough to win, and named so the next person sees why it has to be. */
/* A GRID, not a flex row. Three columns: the field takes what is left, Save
   and Cancel take what they need, and the message spans all three on a second
   row. That is fixed by the template rather than decided by wrapping, so there
   is no width at which the buttons come apart — which is the failure this
   replaces. minmax(0, 1fr) is what lets the field shrink below its content. */
.sheet-field-text > .device-rename-edit {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 6px;
  width: 100%;
}

/* The refusal, beside the field it is about. Full width on its own line so a
   sentence is readable rather than squeezed between two buttons. */
/* The refusal, beside the field it is about, on a line of its own. */
.rename-problem {
  grid-column: 1 / -1;
  margin-top: 2px;
  color: var(--danger);
  font-size: 12px;
  line-height: 1.4;
}


/* ------------------------------------- device and shelf rows on a phone */
/*
 * These rows were laid out for ONE button beside the name — "the name takes
 * the room; the button takes what it needs". There are two now, Rename and
 * Remove, and together they take about half the row: "Steam Machine" wrapped
 * mid-name and the date under it wrapped as well, so a single device read as
 * four ragged lines.
 *
 * The name gets the row to itself, and the controls go under it side by side.
 * Two equal columns rather than shrink-to-fit, so Rename and Remove line up
 * down the list instead of each row placing them somewhere slightly different
 * according to how long its name happens to be.
 *
 * Unchanged above 560px, where a name and two buttons genuinely fit on one
 * line and stacking would just add height.
 */
@media (max-width: 560px) {
  .device-head,
  .shelf-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 8px 10px;
    align-items: center;
  }

  /* The name, the date, and the rename editor all live in here. */
  .device-head .sheet-field-text,
  .shelf-row .sheet-field-text {
    grid-column: 1 / -1;
  }

  /* margin-left: auto was what pushed the single button to the right edge; in
     a two-column grid it would shove Rename off its own column. */
  .device-head .device-rename,
  .shelf-row .device-rename,
  .device-head .danger,
  .shelf-row .danger {
    width: 100%;
    margin-left: 0;
  }

  /* The remove button had a 6px nudge away from Rename when they shared a
     line. The grid gap does that now, and the margin would make the two
     columns unequal. */
  .device-remove { margin-left: 0; }

  /* A website row has THREE buttons, not two, so the third lands alone on a
     new line — and in a two-column grid that means the left column, which put
     Remove under Rename while every other destructive control in this sheet
     sits at the right edge. Pin it to the second column. */
  .website-row .danger { grid-column: 2; }
}

/* ------------------------------------------------------- the phone's bar
   The same shape as the admin console's: a fixed row of icon-over-label
   targets where a thumb already rests. Hidden above 720px, where the header
   has room for the same things and a fixed bar would only eat the window.

   --app-nav-h is what everything else clears: the grid's bottom padding, the
   toast stack and the install banner all read it, so the height is stated
   once and nothing ends up underneath.
*/
:root { --app-nav-h: 58px; }

.app-nav { display: none; }

@media (max-width: 720px) {
  .app-nav {
    position: fixed;
    left: 0; right: 0; bottom: 0;
    z-index: 60;                    /* over the grid, under dialogs */
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: 1fr;
    /* The safe area is not decoration: without it the row sits under the
       iPhone home indicator and the last item cannot be pressed at all. */
    height: calc(var(--app-nav-h) + env(safe-area-inset-bottom, 0px));
    padding-bottom: env(safe-area-inset-bottom, 0px);
    background: var(--bg-raised);
    border-top: 1px solid var(--edge);
  }

  .app-nav-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    background: none;
    border: 0;
    border-top: 2px solid transparent;
    border-radius: 0;
    color: var(--text-dim);
    font-size: 11px;
    text-decoration: none;
    cursor: pointer;
    padding: 0 2px;
    white-space: nowrap;
    overflow: hidden;
  }
  .app-nav-item .nav-icon { font-size: 18px; line-height: 1; }
  .app-nav-item:hover { color: var(--text); background: var(--bg-hover); }
  .app-nav-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

  /* The one entry that is THEIRS rather than a view of the catalogue.
     In the accent, so it reads as somewhere to go and change something — the
     rest of the bar navigates, this one configures. Same glow as the mark in
     the header, on the icon only: on a label it would just look like text
     flickering. */
  .app-nav-mine { color: var(--accent); }
  .app-nav-mine .nav-icon { position: relative; }
  .app-nav-mine .nav-icon::after {
    content: "\2601";
    position: absolute;
    inset: 0;
    /* The lit copy sits ON TOP, for the reason the header's mark documents:
       with both at z-index auto the paint order is arguable and Chrome put the
       glow underneath, which made fading it in change nothing visible. */
    z-index: 1;
    color: var(--accent);
    text-shadow: 0 0 8px rgba(77, 163, 255, 0.9), 0 0 3px rgba(77, 163, 255, 0.8);
    opacity: 0;
    pointer-events: none;
    animation: mark-glow 4s ease-in-out infinite alternate;
  }

  /* The bar carries what this menu held, and Sign out moved into the
     sheet — so keeping it here spends a whole header row on one
     button that duplicates the row of four below. */
  #account-menu { display: none; }

  /* Room to scroll past the bar, or the last row of tiles is unreachable. */
  main { padding-bottom: calc(var(--app-nav-h) + env(safe-area-inset-bottom, 0px) + 24px); }

  /* And the two things that float at the bottom move up by the same amount. */
  .toast-stack { bottom: calc(var(--app-nav-h) + env(safe-area-inset-bottom, 0px) + 14px); }
  .pwa-banner { bottom: calc(var(--app-nav-h) + env(safe-area-inset-bottom, 0px) + 12px); }
}

/* A separator in an action menu, and the one entry that destroys something. */
.dd-sep { height: 1px; background: var(--edge); margin: 4px 2px; }
.dd-item.dd-danger { color: var(--danger); }
.dd-item.dd-danger:hover { background: var(--danger-dim); color: var(--text); }

/* ------------------------------------------------------- install + offline */

/* The offer to add Shelfcloud to a home screen. Built by pwa.js, so it exists
   only on a browser that can actually install — there is no markup for it in
   any page. */
.pwa-banner {
  position: fixed;
  z-index: 60;                        /* over the sticky header, under dialogs */
  left: 12px;
  right: 12px;
  /* Clears the iPhone home indicator and Android's gesture bar rather than
     sitting under them, where the dismiss button cannot be pressed. */
  bottom: calc(12px + env(safe-area-inset-bottom, 0px));
  display: grid;
  grid-template-columns: auto 1fr;
  /* The icon sits beside the title; the description and the buttons both start
     at the card's own left edge, so there is one vertical line down the card
     rather than text indented past the buttons under it. */
  grid-template-areas:
    "mark  title"
    "body  body"
    "actions actions";
  align-items: center;
  column-gap: 12px;
  row-gap: 10px;
  padding: 14px 16px;
  background: var(--bg-raised);
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55);
  /* Starts just below its resting place and slides up; pwa.js adds the -in
     class one layout later so there is a state to animate from. */
  transform: translateY(16px);
  opacity: 0;
  transition: transform 0.24s ease, opacity 0.24s ease;
}

.pwa-banner-in { transform: translateY(0); opacity: 1; }

.pwa-banner-mark { grid-area: mark; display: block; width: 40px; height: 40px; }
.pwa-banner-title { grid-area: title; font-size: 16px; font-weight: 650; }
.pwa-banner-body {
  grid-area: body;
  font-size: 13px;
  color: var(--text-dim);
  min-width: 0;                       /* so long text wraps instead of stretching */
}

.pwa-banner-actions {
  grid-area: actions;
  display: flex;
  gap: 8px;
}

/* Full-width targets on a phone, where this is the only thing on screen worth
   pressing; both buttons the same size, so neither is the accidental one. */
.pwa-banner-actions button { flex: 1; min-height: 44px; }

@media (min-width: 620px) {
  .pwa-banner {
    /* Centred, not pinned to the right corner.
       Both insets stay set and the auto margins do the centring, so the
       slide-in transform stays a plain translateY — centring with
       translateX(-50%) would have to be repeated in .pwa-banner-in or the
       card jumps sideways the moment it animates in. */
    left: 12px;
    right: 12px;
    margin-inline: auto;
    max-width: 460px;
    grid-template-columns: auto 1fr auto;
    /* One row of content: the icon spans both text lines, the buttons sit at
       the end of it. */
    grid-template-areas:
      "mark title actions"
      "mark body  actions";
    row-gap: 2px;
  }
  .pwa-banner-actions button { flex: none; }
}

@media (prefers-reduced-motion: reduce) {
  .pwa-banner { transition: none; transform: none; opacity: 1; }
}

/* The page the service worker serves when a navigation cannot reach the
   network. Centred and short: there is one thing to say and one thing to do. */
.offline-body {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 32px 20px calc(32px + env(safe-area-inset-bottom, 0px));
}

.offline {
  max-width: 34rem;
  text-align: center;
}

.offline-mark { width: 72px; height: 72px; opacity: 0.9; }
.offline h1 { font-size: 24px; margin: 14px 0 10px; }
.offline p { color: var(--text-dim); margin: 0 0 14px; }
.offline-status { font-size: 13px; min-height: 1.5em; }
/* Tabular figures: without them the countdown's width changes with every
   digit and the button twitches once a second. */
.offline-count { font-variant-numeric: tabular-nums; }
/* The margin lives on the button rather than on the paragraph below it:
   `.offline p` sets margin-top to 0 and outranks `.offline-links`, so a
   margin-top there is overruled and the link sits against the button. */
.offline .primary { min-height: 44px; padding-inline: 22px; margin: 6px 0 34px; }
.offline-links { font-size: 14px; }
.offline-foot { font-size: 13px; opacity: 0.75; margin-bottom: 0; }

/* --------------------------------------------------------------- contact */

/* Back one pane, not out of the sheet. Sits where nothing sat before, so the
   title shifts right by exactly its width and the close button does not move —
   a head whose ✕ jumps sideways when you open a thread reads as a new dialog. */
.contact-back {
  padding: 4px 9px;
  font-size: 15px;
  line-height: 1;
  color: var(--text-dim);
}

.contact-form {
  display: block;
  padding: 14px 20px 16px;
  border-top: 1px solid var(--edge);
}

.contact-label {
  display: block;
  margin: 0 0 5px;
  font-size: 12px;
  font-weight: 560;
  color: var(--text-dim);
}

/* This stylesheet carries no bare `input` rule — every form dresses its own
   fields, the way .shelf-add and .website-add do. Without these the browser
   default applies and a white box lands in the middle of a dark sheet. */
.contact-form input,
.contact-form textarea {
  width: 100%;
  margin-bottom: 12px;
  padding: 9px 12px;
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  background: var(--bg-raised);
  color: var(--text);
  font: inherit;
  font-size: 14px;
}

.contact-form input:focus-visible,
.contact-form textarea:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.contact-form input::placeholder,
.contact-form textarea::placeholder { color: var(--text-dim); }

/* Vertical only. A textarea that can be dragged wider than the sheet leaves
   the send button off-screen with no way back. */
.contact-form textarea { resize: vertical; min-height: 92px; line-height: 1.5; }

.contact-send-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

/* Tabular figures: the count changes on every keystroke, and without them the
   number's width changes with it and the row twitches while you type. */
.contact-count {
  color: var(--text-dim);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}

.contact-count.over { color: var(--danger); }

.contact-muted {
  margin: 14px 20px 0;
  padding: 12px 14px;
  border: 1px solid var(--danger);
  border-radius: var(--radius);
  background: rgba(255, 107, 107, 0.08);
  color: var(--danger);
  font-size: 13px;
}

/* One conversation in the list. A button rather than a link: there is no URL
   for a thread — the whole app is one page — and a link with href="#" is a
   keyboard trap dressed up as navigation. */
.contact-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  margin-bottom: 8px;
  padding: 11px 13px;
  border: 1px solid var(--edge);
  border-radius: var(--radius);
  background: var(--bg);
  text-align: left;
  cursor: pointer;
}

.contact-item:hover { background: var(--bg-hover); border-color: var(--accent); }

.contact-item-text { flex: 1; min-width: 0; }

.contact-item-subject {
  display: block;
  overflow: hidden;
  font-size: 14px;
  font-weight: 560;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.contact-item-when { display: block; margin-top: 2px; color: var(--text-dim); font-size: 12px; }

/* Status, as a word rather than only a colour: three pills that differ by hue
   alone are three identical pills to anyone who cannot separate them. */
.contact-pill {
  flex: 0 0 auto;
  padding: 2px 9px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: 650;
  text-transform: capitalize;
}

.contact-pill.open { background: rgba(77, 163, 255, 0.16); color: var(--accent); }
.contact-pill.answered { background: rgba(53, 194, 106, 0.14); color: var(--ok); }
.contact-pill.closed { background: rgba(139, 152, 173, 0.16); color: var(--text-dim); }

.contact-new {
  flex: 0 0 auto;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--accent);
  color: #04121f;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.02em;
}

/* The thread. Yours sits against the left edge; an answer is inset from it and
   carries the accent rule, so who said what is legible from the shape of the
   column before a word is read. */
.contact-thread { padding: 14px 20px 4px; }

.contact-bubble {
  margin-bottom: 10px;
  padding: 11px 14px;
  border: 1px solid var(--edge);
  border-left: 3px solid var(--edge);
  border-radius: var(--radius);
  background: var(--bg);
}

.contact-bubble.from-admin {
  margin-right: 0;
  margin-left: 18px;
  border-color: rgba(77, 163, 255, 0.28);
  border-left-color: var(--accent);
  background: rgba(77, 163, 255, 0.06);
}

.contact-who {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 6px;
  font-size: 12.5px;
  font-weight: 620;
}

.contact-bubble.from-admin .contact-who { color: var(--accent); }
.contact-who time { color: var(--text-dim); font-size: 11.5px; font-weight: 400; }

/* pre-wrap, so the paragraphs somebody typed survive; break-word, so a pasted
   log line or a long URL wraps instead of widening the sheet. */
.contact-text {
  font-size: 14px;
  line-height: 1.55;
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

.contact-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 20px 0;
}

.contact-head-subject { flex: 1; min-width: 0; font-size: 15px; font-weight: 620; }

.contact-closed {
  margin: 0;
  padding: 18px 20px 22px;
  border-top: 1px solid var(--edge);
  color: var(--text-dim);
  font-size: 13px;
  text-align: center;
}

.contact-empty { color: var(--text-dim); font-size: 13px; }

/* The unread dot on the bar entry and in the account menu. Positioned on the
   icon rather than the label: the label is the part that gets clipped when
   five items share a phone's width, and a dot that clips is a dot that lies. */
.nav-dot {
  position: absolute;
  top: -1px;
  right: -5px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 2px var(--bg-raised);
}

@media (max-width: 720px) {
  /* Five items now, not four. "My Shelfcloud" is the only long label and at
     five columns it no longer fits on one line, so labels wrap instead of
     being clipped — a clipped "My Shelfcl" is worse than two short lines. */
  .app-nav-item { font-size: 10.5px; line-height: 1.15; white-space: normal; }
  .app-nav-item > span:last-child { text-align: center; }
  .app-nav-item .nav-icon { position: relative; }
}
