/* ============================================================
   gamescom 2026 guide — "Hall Signage"

   Trade-fair wayfinding crossed with print. Typography and hard
   edges do the work: no gradients, no blurred elevation, no
   rounded pill soup. Hall numbers are set on solid plates because
   that is the one thing you actually read while walking, and each
   exhibitor is a discrete panel rather than a cell of one table.

   Two earlier directions (a light "Fanzine" and a monospace
   "Console") were built on these same tokens and dropped once
   this one was chosen; recover them from css/themes.css in the
   history if you ever want to compare again.
   ============================================================ */

:root {
  /* ---- ground & ink (warm neutral, deliberately not navy) ---- */
  --ground:    #09090a;
  --ground-2:  #141416;
  --ground-3:  #1c1c1f;
  --rule:      #2b2c30;
  --rule-soft: #1f2023;
  --ink:       #f2efe9;
  --ink-mid:   #b3b1ab;
  --ink-dim:   #86847f;

  /* ---- one signal colour, used sparingly ---- */
  --signal:     #ff4d17;
  --signal-ink: #0b0b0c;
  --signal-wash: rgba(255, 77, 23, .12);
  --grain:       rgba(255, 255, 255, .025);

  /* The two areas' own colours, taken from the official hall plan's
     `farbenvorgabe` and carried through data/hallplan/index.json — so the
     map's hall washes, the card plates and the trade cards all mean the
     same thing by them. tools/fetch-hallplan.mjs re-checks both against the
     source on every snapshot, so a repaint by Koelnmesse stops that run
     rather than leaving these two lines a season behind. The ink each one
     needs inverts: see .hall-plate[data-area]. */
  --entertainment: #00b9ff;
  --business:      #7800ff;

  /* ---- status ---- */
  --ok:      #2fbf85;
  --warn:    #e8a33a;
  --neutral: #86847f;
  --alert:   #f2564f;

  /* ---- type ---- */
  --font-ui:      "Archivo", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-display: "Archivo Narrow", "Archivo", system-ui, sans-serif;
  --font-mono:    "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
  /* poster weight, used only for structural headings — exhibitor names stay on
     --font-display so 38 cards don't all shout at once */
  --font-poster:  "Anton", "Archivo Narrow", system-ui, sans-serif;

  /* ---- shape: near-square, signage-like ---- */
  --radius:   2px;
  --radius-sm: 2px;
  --border:   1px solid var(--rule);
  --wrap:     1200px;

  /* Hard offset, never blurred. On a near-black ground a black shadow is
     invisible, so the offset is LIGHTER than the ground — same printed
     second-layer effect as ink-on-paper, inverted. */
  --plate-shadow:       5px 5px 0 #212125;
  --plate-shadow-hover: 8px 8px 0 #2c2c31;

  font-size: 16px;
  color-scheme: dark;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

html { -webkit-text-size-adjust: 100%; }

body {
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: .95rem;
  line-height: 1.55;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  -webkit-font-smoothing: antialiased;
  /* barely-there halftone; gives the ground a printed grain instead of flat ink */
  background-image: radial-gradient(var(--grain) .5px, transparent .5px);
  background-size: 4px 4px;
}

.wrap { max-width: var(--wrap); margin: 0 auto; padding: 0 24px; width: 100%; }
.hidden { display: none !important; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  /* These are sentences, not labels — inheriting a `text-transform: uppercase`
     from the control they sit in makes some screen readers spell them out. */
  text-transform: none; letter-spacing: normal;
}

:focus-visible {
  outline: 2px solid var(--signal);
  outline-offset: 2px;
}

.skip-link {
  position: absolute; left: 24px; top: -60px; z-index: 200;
  background: var(--signal); color: var(--signal-ink);
  padding: 10px 16px; font-weight: 700; text-decoration: none;
  transition: top .15s;
}
.skip-link:focus { top: 12px; }

/* small letterspaced label used everywhere instead of icons/emoji */
.row-label,
.overline,
.field-label,
.block-head,
.section-num,
.tab-index,
.brand-kicker {
  font-family: var(--font-mono);
  font-size: .66rem;
  font-weight: 500;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ink-dim);
}

/* ============================================================
   header
   ============================================================ */
.site-header {
  background: var(--ground);
  border-bottom: 1px solid var(--rule);
  position: sticky;
  top: 0;
  z-index: 50;
}

/* Two columns, two rows: the wordmark on the left across both, the dates and
   the controls stacked on the right. A grid rather than a flex row because
   the controls belong *under* the meta block, not beside it — sitting beside
   it, they push the dates and countdown off the right edge they are aligned
   to. The phone layout in the responsive section re-places the same items. */
.header-inner {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: end;
  column-gap: 20px;
  row-gap: 6px;
  padding-top: 20px;
  padding-bottom: 16px;
}

.brand {
  grid-column: 1;
  grid-row: 1 / span 2;
  /* content width, not column width — a stretched grid item would make the
     empty half of the masthead a link to the top of the exhibitor list */
  justify-self: start;
  display: flex;
  align-items: center;
  gap: 14px;
  text-decoration: none;
  color: inherit;
}
.brand-mark {
  display: grid;
  place-items: center;
  width: 46px; height: 46px;
  background: var(--signal);
  color: var(--signal-ink);
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 1.25rem;
  letter-spacing: -.03em;
  border-radius: var(--radius-sm);
}
.brand-text { display: flex; flex-direction: column; gap: 1px; }
.brand-title {
  font-family: var(--font-poster);
  font-size: 1.6rem;
  font-weight: 400;
  line-height: 1.05;
  letter-spacing: .005em;
  text-transform: uppercase;
}
.brand-year { color: var(--ink-dim); font-weight: 600; }

.header-meta {
  grid-column: 2;
  grid-row: 1;
  text-align: right;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.meta-line { color: var(--ink-mid); font-size: .84rem; }
.countdown {
  font-family: var(--font-mono);
  font-size: .82rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--signal);
  font-variant-numeric: tabular-nums;
  /* Empty in the markup: renderCountdown writes it, and that waits on
     data/event.json — seconds away on a hall's LTE. An empty <p> is nothing
     high, so the line arriving grew the masthead by its own height and took
     the tab strip, the whole of #main and the footer down with it. It was the
     largest layout shift on the page, and it fired on every cold load.

     One line, reserved from the first paint. Never a guess at what will
     land in it: every string it can hold is one short line at every width
     the guide is read at — "T−5 days", "● Live now", "See you next year",
     and their German counterparts. The em fallback is the same number
     (line-height 1.55, unitless, times this font-size) for browsers from
     before lh units. */
  min-height: 1.55em;
  min-height: 1lh;
}

/* The masthead's stamped controls as one row rather than a stack of
   individually aligned buttons — each aligning itself against the meta text
   above is what left the language toggle floating mid-air on a phone. */
.header-actions {
  grid-column: 2;
  grid-row: 2;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: 8px;
}

/* ---- tabs: signage strip, not browser chrome ---- */
/* position: relative for the scroll cue below, which cannot live inside .tabs:
   that element is the scroll port (an absolute child would scroll away with the
   tabs) and it is the role="tablist", whose children are supposed to be tabs. */
.tabs-outer { border-top: 1px solid var(--rule-soft); position: relative; }
.tabs { display: flex; overflow-x: auto; scrollbar-width: none; padding: 0 24px; }
.tabs::-webkit-scrollbar { display: none; }

/* The row scrolls, and hiding its scrollbar (above) took away the only sign
   that it does. Four labels measure ~534px against a 320–430px phone, and the
   cut lands cleanly between two tabs, so the row reads as ending at "03 Event
   info" — leaving "04 Updates" reachable only by a swipe nobody is invited to
   make.

   So the cut is made deliberate: a plate at whichever edge still has tabs past
   it, carrying a chevron, scrolling the row when tapped. A plate rather than a
   fade because a fade is a gradient, and this design does not have any — the
   hard edge and the hairline are the same vocabulary as the hall plates.

   Hidden from assistive tech (app.js sets aria-hidden and tabindex="-1"): a
   keyboard user reaches every tab by tabbing, and the browser scrolls each
   into view on focus, so the cue would be two extra stops in the middle of the
   nav offering something already had. It is a pointer affordance for a pointer
   problem.

   Shown only when there is something past the edge; app.js toggles .on. */
.tabs-cue {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 38px;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  /* --ground, the page's own colour, so the plate reads as the row being cut
     off rather than as a control sitting on top of it. */
  background: var(--ground);
  color: var(--ink-dim);
  font-family: var(--font-mono);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
}
.tabs-cue.on { display: flex; }
.tabs-cue:hover { color: var(--ink); }
/* The hairline is the cut: it stands between the plate and the tab it covers,
   so the row is visibly continuing rather than ending. */
.tabs-cue-end { right: 0; border-left: 1px solid var(--rule-soft); }
.tabs-cue-start { left: 0; border-right: 1px solid var(--rule-soft); }

.tab {
  background: none;
  border: none;
  border-bottom: 3px solid transparent;
  color: var(--ink-dim);
  font: inherit;
  font-weight: 600;
  font-size: .88rem;
  letter-spacing: .01em;
  padding: 13px 20px 11px;
  margin-right: -1px;
  cursor: pointer;
  white-space: nowrap;
  display: flex;
  align-items: baseline;
  gap: 8px;
  transition: color .12s, border-color .12s;
}
/* .tab sets display:flex, which beats the UA sheet's [hidden] rule whatever
   its specificity — so the Today tab, hidden off-show, needs saying twice. */
.tab[hidden] { display: none; }
/* The row's left edge aligns with the content, whichever tab currently starts
   it: Today when the show is on, Exhibitors the rest of the year. */
.tab:first-child,
.tab[hidden] + .tab { padding-left: 0; }
.tab .tab-index { transition: color .12s; }
/* A live dot rather than a number marks the tabs that only mean something
   while the show runs. Today always wears one; Live queues wears one on the
   show days and takes its place in the standing 01-05 sequence off them, when
   it is a description of what is coming rather than a live surface. */
.tab-live { color: var(--signal); font-size: .7rem; }
.tab:hover { color: var(--ink); }
.tab.active { color: var(--ink); border-bottom-color: var(--signal); }
.tab.active .tab-index { color: var(--signal); }

/* ============================================================
   layout
   ============================================================ */
main { flex: 1; padding-top: 34px; padding-bottom: 72px; }
/* A tab that is not on screen has its contents skipped, not taken out of the
   layout.

   display:none throws away every box under the view being left and builds
   every box under the view being opened from nothing, and the exhibitor grid
   is six thousand elements of boxes. On a phone that was 120-140ms of
   rendering charged to the tap — nearly all of what switching tabs cost, and
   none of it script the profiler could point at. content-visibility keeps the
   layout it already worked out and hands it straight back, which roughly
   halved that rendering in both directions.

   The two are the same in the ways that matter for a tab panel nobody has
   selected: skipped content is invisible to find-in-page, to assistive
   technology, and to focus. They differ in one way that does — display:none
   blurs whatever was focused inside it and this does not — so showView()
   hands that focus back to the document itself.

   Kept as an override of the display swap rather than replacing it, so a
   browser that has never heard of the property gets exactly the rule it
   always had. */
.view { display: none; }
.view.active { display: block; animation: rise .2s ease-out; }
@supports (content-visibility: hidden) {
  .view { display: block; content-visibility: hidden; contain-intrinsic-size: 0 0; }
  .view.active { content-visibility: visible; }
}

@keyframes rise {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: none; }
}

.lede {
  border-left: 3px solid var(--signal);
  padding: 2px 0 2px 18px;
  margin-bottom: 32px;
}
.lede h2 {
  font-family: var(--font-poster);
  font-size: 2rem; font-weight: 400;
  text-transform: uppercase; letter-spacing: .005em; line-height: 1.05;
  margin-bottom: 8px;
}
.lede p { color: var(--ink-mid); }

.section-title {
  font-family: var(--font-poster);
  font-size: 1.35rem;
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: .01em;
  margin: 40px 0 4px;
  padding-bottom: 8px;
  border-bottom: 1px solid var(--rule);
  display: flex;
  align-items: baseline;
  gap: 12px;
}
.section-title .section-num { color: var(--signal); }
.section-sub { color: var(--ink-dim); font-size: .85rem; margin: 10px 0 16px; }
.section-title + .day-board,
.section-title + .priority-table,
.section-title + .tips { margin-top: 16px; }

/* ============================================================
   toolbar
   ============================================================ */
.toolbar {
  border: var(--border);
  border-radius: var(--radius);
  margin-bottom: 24px;
  background: var(--ground-2);
}
.toolbar-row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--rule-soft);
  flex-wrap: wrap;
}
.toolbar-row:last-child { border-bottom: none; }
.toolbar-row .row-label { flex: 0 0 76px; padding-top: 1px; }

.toolbar-search { padding: 0; gap: 0; }
.field { display: flex; flex-direction: column; gap: 0; }
.field-search { flex: 1; min-width: 240px; }
.field-sort { border-left: 1px solid var(--rule-soft); }
.field-label { padding: 9px 16px 0; }

#search, #sort, #queues-search {
  background: none;
  border: none;
  color: var(--ink);
  font: inherit;
  font-size: .95rem;
  padding: 2px 16px 11px;
  width: 100%;
}
#search::placeholder, #queues-search::placeholder { color: var(--ink-dim); }
#search:focus-visible, #sort:focus-visible, #queues-search:focus-visible { outline-offset: -2px; }
#sort { cursor: pointer; font-size: .88rem; padding-right: 16px; }
#sort option { background: var(--ground-2); color: var(--ink); }

/* Collapsible filter drawer — open by default on desktop, closed on a phone
   where the full panel would otherwise fill the whole first screen. */
.toolbar-more { border-top: 1px solid var(--rule-soft); }
.toolbar-summary {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 16px;
  cursor: pointer;
  list-style: none;
  user-select: none;
}
.toolbar-summary::-webkit-details-marker { display: none; }
.toolbar-summary .row-label { flex: 0 0 76px; }
.toolbar-summary::after {
  content: "";
  width: 7px; height: 7px;
  border-right: 1.5px solid var(--ink-dim);
  border-bottom: 1.5px solid var(--ink-dim);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .15s;
  order: -1;
  margin-right: -6px;
}
.toolbar-more[open] > .toolbar-summary::after { transform: rotate(-135deg) translate(-3px, -3px); }
.toolbar-more[open] > .toolbar-summary { border-bottom: 1px solid var(--rule-soft); }
.toolbar-summary:hover .filter-summary { color: var(--ink); }

.filter-summary { font-size: .84rem; color: var(--ink-mid); }
.filter-summary[data-active="true"] { color: var(--signal); font-weight: 600; }

.filter-row { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }

.chip {
  background: none;
  border: 1px solid var(--rule);
  color: var(--ink-mid);
  border-radius: var(--radius-sm);
  padding: 4px 11px;
  font-family: var(--font-ui);
  font-size: .78rem;
  font-weight: 600;
  cursor: pointer;
  transition: color .12s, border-color .12s, background .12s;
}
.chip:hover { color: var(--ink); border-color: var(--ink-dim); }
.chip.active { background: var(--ink); border-color: var(--ink); color: var(--ground); }
.chip.hall-chip { font-family: var(--font-mono); font-weight: 500; font-size: .75rem; }
.chip.hall-chip.active,
.chip.age-chip.active { background: var(--signal); border-color: var(--signal); color: var(--signal-ink); }
/* Each hall chip carries its hall's own colour on the official plan, the same
   fill its card plates take — so the row doubles as the legend for them. The
   edge is the whole cue and it survives the active state, which stays the
   signal orange like every other chip in the app; the map's hall row makes
   exactly this trade and the two now agree. The 2px comes off the padding so
   an edged chip sets its number in the same place "All" does. */
.chip.hall-chip[data-area="entertainment"] { border-left: 3px solid var(--entertainment); padding-left: 9px; }
.chip.hall-chip[data-area="business"]      { border-left: 3px solid var(--business);      padding-left: 9px; }
/* The badge pair sits above the filter rows. Amber is the guide's
   badge-required colour — the directory's stand plates and the map's hall
   groups carry the same one — and it stays that here: what the switch says is
   "a ticket does not open this", which is about the door, not the hall. Which
   halls it unlocks is the purple edge that appears in the row below. */
.chip.badge-chip-trade { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 34%, var(--rule)); }
.chip.badge-chip-trade:hover { border-color: var(--warn); }
.chip.badge-chip-trade.active { background: var(--warn); border-color: var(--warn); color: var(--signal-ink); }

.toggle {
  display: inline-flex; align-items: center; gap: 8px;
  color: var(--ink-mid); font-size: .84rem; cursor: pointer; user-select: none;
}
.toggle:hover { color: var(--ink); }
.toggle input { accent-color: var(--signal); width: 15px; height: 15px; }

.reset {
  background: none; border: none; color: var(--signal);
  font: inherit; font-size: .8rem; font-weight: 600;
  cursor: pointer; text-decoration: underline; text-underline-offset: 3px;
  padding: 0;
}
.toolbar-foot { padding-top: 14px; padding-bottom: 14px; }
.result-count {
  margin-left: auto;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: .74rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}

/* ============================================================
   exhibitor cards
   ============================================================ */
/* Cards are discrete printed plates, not cells of one table: real gaps plus a
   hard un-blurred offset shadow. Blurred shadows read as generic material
   elevation; a hard offset reads as something laid on top of the page. */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
  gap: 20px;
}

.card {
  min-width: 0;
  background: var(--ground-2);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: var(--plate-shadow);
  padding: 0;
  display: flex;
  flex-direction: column;
  transition: background .12s, box-shadow .12s, transform .12s;
}
/* Arriving from the hall map's "open in guide": say which of the 53 cards
   you were sent to, then get out of the way. The global reduced-motion
   rule collapses this to an instant, which is the right amount of it. */
@keyframes card-landed {
  from { box-shadow: 0 0 0 2px var(--signal), var(--plate-shadow); }
  to   { box-shadow: 0 0 0 2px transparent, var(--plate-shadow); }
}
.card-landed { animation: card-landed 1.8s ease-out; }

.card:hover {
  background: var(--ground-3);
  transform: translate(-2px, -2px);
  box-shadow: var(--plate-shadow-hover);
}

/* --- head: the hall number as a signage plate --- */
.exh-head { display: flex; gap: 0; border-bottom: 1px solid var(--rule-soft); }

/* The plate's box. It only exists so the face switch has a corner to sit in;
   without a second booth it is invisible and the plate behaves as it always
   did. */
.hall-plate { flex: 0 0 108px; position: relative; display: flex; }
.hall-marker {
  flex: 1 1 auto;
  padding: 14px 12px;
  position: relative;
  background: var(--hall, var(--signal));
  color: var(--hall-ink, var(--signal-ink));
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 2px;
}
.hall-marker[data-state="tba"],
.hall-marker[data-state="absent"] { background: var(--ground-3); color: var(--ink-dim); }

/* --- what the plate's colour says ---

   The plate is filled with the colour Koelnmesse gives that hall on its own
   plan: cyan for the entertainment halls, purple for the business ones. They
   are the fills the map washes those halls with, so a card plate, a hall on
   the map and the official plan all mean the same thing by a colour — and a
   card you turn over teaches that in one gesture, because both faces are
   plates and the colour is the whole difference between them.

   Halls 5 and 10 are left uncoloured on the official plan (they hold several
   areas each). The snapshot files them as what they are, a consumer ticket's
   halls, and the map washes them cyan — so their plates do too. A hall the
   guide can't place at all keeps the signal colour, which is what every plate
   used to be.

   The ink inverts, measured not guessed: cyan needs near-black (8.79:1, the
   off-white would be 1.95:1), purple needs the warm off-white (5.64:1,
   near-black would be 3.04:1). */
.hall-plate[data-area="entertainment"] { --hall: var(--entertainment); --hall-ink: var(--signal-ink); }
.hall-plate[data-area="business"]      { --hall: var(--business);      --hall-ink: var(--ink); }

/* A guessed location can no longer be an amber plate: amber on cyan is
   1.04:1, and the fill is now saying which hall you are walking to. The map's
   other half of that language carries it instead — the dashed edge it draws
   round an unconfirmed booth — set in the plate's own ink so it holds on
   either fill. The "· unconf." under the booth number still says it in
   words, and the plate's title attribute in full. */
.hall-marker[data-state="unconfirmed"]::after {
  content: "";
  position: absolute;
  inset: 3px;
  border: 1px dashed currentColor;
  opacity: .55;
  pointer-events: none;
}

/* The way to the other booth, notched into the plate's corner: the corner a
   thumb is already near, and close enough to the big plate that the swap
   reads as an exchange rather than a jump across the card.

   Top-right rather than bottom-right, because the two faces carry different
   booth numbers and a long one wraps to a second line — anchored to the
   bottom, the square would sit at a different height on each face and move
   out from under the finger that just tapped it. The top edge does not move.

   The square wears the *other* face's hall colour — it is that plate in
   miniature, so the swap is legible before you make it: purple to cross into
   the business halls, cyan to come back. Its own vars, not the plate's,
   because it sits inside the plate it does not match. */
.face-switch {
  position: absolute;
  right: 0;
  top: 0;
  width: 34px;
  height: 30px;
  padding: 0;
  border: 0;
  border-bottom: 1px solid var(--ground);
  border-left: 1px solid var(--ground);
  background: var(--face, var(--signal));
  color: var(--face-ink, var(--signal-ink));
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
  transition: filter .12s;
}
.face-switch[data-area="entertainment"] { --face: var(--entertainment); --face-ink: var(--signal-ink); }
.face-switch[data-area="business"]      { --face: var(--business);      --face-ink: var(--ink); }
.face-switch:hover { filter: brightness(1.18); }
.face-switch:focus-visible { outline: 2px solid var(--ink); outline-offset: -3px; }
/* The other side's saved state, so a saved trade stop is not invisible until
   you turn the card over. Same dot the map's hall chips use. */
.face-saved {
  position: absolute;
  bottom: 3px;
  right: 3px;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: currentColor;
}
.face-switch:not([data-face-saved="true"]) .face-saved { display: none; }

/* The plate doubles as the link to that booth on the hall map when the
   map covers its hall (see hallMarker) — an anchor wearing the same
   plate, not a new control. */
a.hall-marker { text-decoration: none; }
.hall-map-cue {
  font-family: var(--font-mono);
  font-size: .56rem; letter-spacing: .1em; text-transform: uppercase;
  opacity: .62;
  margin-top: 2px;
}
a.hall-marker:hover .hall-map-cue,
a.hall-marker:focus-visible .hall-map-cue { opacity: 1; }

/* The same link, in running text: every hall named in the planner rows opens
   that hall on the map (see hallLink). These sit inside dense lists, so the
   affordance is a quiet underline rather than another plate — it keeps the
   row's own colour until you reach for it, then takes the accent. */
.hall-link {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
.hall-link:hover,
.hall-link:focus-visible { color: var(--signal); text-decoration-color: currentColor; }

.hall-kicker {
  font-family: var(--font-mono);
  font-size: .58rem; letter-spacing: .16em; text-transform: uppercase;
  color: inherit; opacity: .7;
}
.hall-num {
  font-family: var(--font-mono);
  font-size: 1.75rem;
  font-weight: 700;
  line-height: 1;
  letter-spacing: -.04em;
  font-variant-numeric: tabular-nums;
}
.hall-marker[data-state="tba"] .hall-num,
.hall-marker[data-state="absent"] .hall-num { font-size: 1rem; letter-spacing: 0; }
/* A plate can now carry several stands — Nintendo holds four — and the
   comma is where the line is allowed to turn. break-all broke wherever
   the 108 px ran out, which split "A020/B019" after its first character
   and left a booth number nobody could read; break-word keeps each stand
   whole and only breaks inside one that could not fit a line on its own. */
.hall-booth {
  font-family: var(--font-mono);
  font-size: .68rem;
  color: inherit; opacity: .78;
  overflow-wrap: break-word;
}

.exh-id { padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; justify-content: center; min-width: 0; }
/* Both lines have to stay inside the column between the two plates. The
   category is one unbreakable word in both languages — PUBLISHER, PUBLISHER
   · SÜDKOREA — so on the narrowest phones it ends in an ellipsis rather
   than running out under the action corner, and a name with no space in it
   breaks rather than doing the same. */
.exh-id .overline { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.exh-id h3 {
  font-family: var(--font-display);
  font-size: 1.18rem;
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -.005em;
  overflow-wrap: break-word;
}

.card-body { padding: 14px 16px; display: flex; flex-direction: column; gap: 14px; flex: 1; }
.desc { color: var(--ink-mid); font-size: .87rem; }

/* --- lineup block --- */
.block-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 10px;
  padding-bottom: 6px;
  margin-bottom: 8px;
  border-bottom: 1px solid var(--rule-soft);
}

.games { list-style: none; display: flex; flex-direction: column; }
.game {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto auto;
  gap: 2px 10px;
  padding: 7px 0 7px 14px;
  border-top: 1px solid var(--rule-soft);
  position: relative;
  font-size: .87rem;
}
.game:first-child { border-top: none; }
.game::before {
  content: "";
  position: absolute; left: 0; top: 13px;
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--neutral);
}
.game[data-status="confirmed"]::before { background: var(--ok); }
.game[data-status="expected"]::before  { background: var(--warn); }
.game[data-status="rumored"]::before   { background: var(--neutral); }

.game-title { font-weight: 600; }
.game-main { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; min-width: 0; }
.game-plat {
  font-family: var(--font-mono);
  font-size: .64rem;
  letter-spacing: .06em;
  color: var(--ink-dim);
  white-space: nowrap;
  align-self: center;
}
.game-note { grid-column: 1 / -1; color: var(--ink-dim); font-size: .78rem; line-height: 1.45; }

.badge {
  font-family: var(--font-mono);
  font-size: .6rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: .1em;
  padding: 1px 6px;
  border-radius: var(--radius-sm);
  white-space: nowrap;
  border: 1px solid transparent;
}
.badge-playable { color: var(--signal); border-color: var(--signal-wash); background: var(--signal-wash); }
/* Fresh off gamescom's own stage — the entertainment area's cyan, as a quiet
   outline so the playable wash keeps the last word on a row wearing both. */
.badge-onl { color: var(--entertainment); border-color: rgba(0, 185, 255, .3); }
.badge-age { background: transparent; }
.badge-age[data-age-status="confirmed"] { color: var(--alert); border-color: var(--alert); }
.badge-age[data-age-status="expected"] { color: var(--warn); border-color: var(--warn); }
.block-head .badge-age { margin-left: 9px; }
.exh-id h3 .badge-age { margin-left: 6px; vertical-align: .2em; }

/* The one hand-applied element in the layout, and deliberately the only one.
   Once per card it reads as a stamp; repeated down every game row, the tilts
   land at different x positions and read as jitter. */
.stamp {
  display: inline-block;
  margin-left: 9px;
  background: var(--signal);
  color: var(--signal-ink);
  font-family: var(--font-mono);
  font-size: .62rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  padding: 2px 7px;
  border-radius: var(--radius-sm);
  transform: rotate(-2.5deg);
  box-shadow: 2px 2px 0 rgba(0, 0, 0, .45);
}
.badge-status[data-status="expected"] { color: var(--warn); border-color: rgba(232,163,58,.25); }
.badge-status[data-status="rumored"]  { color: var(--neutral); border-color: var(--rule); }

.more-games {
  align-self: flex-start;
  margin-top: 8px;
  background: none; border: none; padding: 0;
  color: var(--signal);
  font-family: var(--font-mono);
  font-size: .7rem; letter-spacing: .08em; text-transform: uppercase;
  cursor: pointer;
}
.more-games:hover { text-decoration: underline; text-underline-offset: 3px; }

/* ============================================================
   full official directory
   ------------------------------------------------------------
   Deliberately not a second grid: cards are the edited guide, this is the raw
   list underneath it. It reads as a register — mono booth numbers, ruled rows,
   no plate shadow — so nothing about it competes with the cards above.
   ============================================================ */

.directory {
  margin-top: 34px;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--ground-2);
}
.directory-summary {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 13px 16px;
  cursor: pointer;
  list-style: none;
  user-select: none;
}
.directory-summary::-webkit-details-marker { display: none; }
.directory-summary::after {
  content: "";
  width: 7px; height: 7px;
  border-right: 1.5px solid var(--ink-dim);
  border-bottom: 1.5px solid var(--ink-dim);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .15s;
  order: -1;
  margin-right: -6px;
}
.directory[open] > .directory-summary::after { transform: rotate(-135deg) translate(-3px, -3px); }
.directory[open] > .directory-summary { border-bottom: 1px solid var(--rule-soft); }
.directory-lede { font-size: .84rem; color: var(--ink-mid); min-width: 0; }
.directory-summary:hover .directory-lede { color: var(--ink); }

.directory-note {
  margin: 0;
  padding: 12px 16px 0;
  font-size: .8rem;
  color: var(--ink-dim);
  line-height: 1.5;
}
.directory-note:empty { display: none; }

.dir-list {
  list-style: none;
  margin: 12px 0 0;
  padding: 0;
}
.dir-row {
  display: grid;
  /* name takes the slack; country and stands stay in their own columns so the
     booth numbers line up into a readable rail down the right, with the save
     button in a track of its own past them */
  grid-template-columns: minmax(0, 1fr) minmax(0, 130px) minmax(0, 250px) auto;
  gap: 6px 14px;
  align-items: baseline;
  padding: 8px 16px;
  border-top: 1px solid var(--rule-soft);
  font-size: .85rem;
}
.dir-row:first-child { border-top: none; }
.dir-row:hover { background: var(--ground-3); }

.dir-name { min-width: 0; color: var(--ink); }
.dir-name a { color: inherit; text-decoration: none; }
.dir-name a:hover { color: var(--signal); text-decoration: underline; text-underline-offset: 3px; }
/* The entity a branded row is registered under. Its own line, quieter than the
   name above it: it is there to explain the row's link and its map label, not
   to compete with the name someone is scanning for. */
.dir-filed {
  display: block;
  color: var(--ink-dim);
  font-size: .72rem;
  line-height: 1.35;
  overflow-wrap: anywhere;
}
.dir-country { color: var(--ink-dim); font-size: .78rem; }

.dir-stands { display: flex; flex-wrap: wrap; gap: 4px 10px; min-width: 0; }
.dir-stand {
  font-family: var(--font-mono);
  font-size: .72rem;
  letter-spacing: .04em;
  color: var(--ink-mid);
  white-space: nowrap;
}
.dir-stand b { color: var(--ink); font-weight: 700; }
/* A stand in a hall the map draws is a link on the whole chip — the
   underline rides on the hall/booth alone, so the "at <host>" tail stays
   a note rather than looking like part of the destination. */
a.dir-stand { text-decoration: none; }
a.dir-stand .dir-stand-where {
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
a.dir-stand:hover .dir-stand-where,
a.dir-stand:focus-visible .dir-stand-where { text-decoration-color: currentColor; }
a.dir-stand:hover,
a.dir-stand:focus-visible { color: var(--signal); }
a.dir-stand:hover b,
a.dir-stand:focus-visible b { color: var(--signal); }
/* which curated booth this stand actually sits on — the 172 Indie Arena Booth
   studios are meaningless without it */
.dir-stand i {
  font-style: normal;
  font-family: var(--font-ui);
  color: var(--ink-dim);
  white-space: normal;
}
.dir-stand-trade { color: var(--ink-dim); }
.dir-stand-trade b { color: var(--warn); }
.dir-stand-tba { color: var(--ink-dim); }

.dir-more {
  display: block;
  margin: 14px 16px;
  font-family: var(--font-mono);
  font-size: .74rem;
  letter-spacing: .08em;
  text-transform: uppercase;
}

/* The button keeps its own baseline: .dir-row aligns on the text baseline,
   which hangs a bare button off the bottom of the row it belongs to. */
.dir-row .row-actions { align-self: center; }

@media (max-width: 640px) {
  /* Narrow, the row stacks — but the save button stays beside the name
     rather than taking a fourth line of its own under the booth numbers. */
  .dir-row { grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; }
  .dir-country, .dir-stands { grid-column: 1; }
  .dir-row .row-actions { grid-column: 2; grid-row: 1 / span 3; align-self: start; }
  .dir-country { font-size: .74rem; }
}

/* ============================================================
   trade exhibitors
   ------------------------------------------------------------
   The same register as the directory above — same rows, same booth rail —
   with two things the directory deliberately doesn't have: a save button, and
   a category tag row. Amber runs through it because amber is already the
   guide's "you need a badge for this" colour, set on the directory's business
   stand plates and on the map's hall groups.
   ============================================================ */

.trade-section { border-color: color-mix(in srgb, var(--warn) 26%, var(--rule)); }
.trade-section > .directory-summary .row-label { color: var(--warn); }

/* Closed, this is the whole feature: what the business area is, and the one
   switch. It reads as prose rather than a settings row on purpose. */
.trade-gate { padding: 14px 16px 16px; }
.trade-gate p {
  margin: 0 0 10px;
  font-size: .86rem;
  line-height: 1.6;
  color: var(--ink-mid);
  max-width: 68ch;
}
.trade-gate strong { color: var(--ink); font-weight: 700; }
.trade-enable {
  margin-top: 4px;
  padding: 10px 16px;
  border: 1px solid var(--warn);
  border-radius: 999px;
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  color: var(--warn);
  font-family: var(--font-mono);
  font-size: .76rem;
  letter-spacing: .06em;
  text-transform: uppercase;
  cursor: pointer;
}
.trade-enable:hover { background: color-mix(in srgb, var(--warn) 22%, transparent); }

/* Turning it back off lives in the note it belongs to, not in a toolbar. */
.linkish {
  border: 0;
  padding: 0;
  background: none;
  color: var(--ink-mid);
  font: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-color: var(--rule);
  cursor: pointer;
  white-space: nowrap;
}
.linkish:hover { color: var(--signal); text-decoration-color: currentColor; }

.trade-cats { padding: 12px 16px 0; }

/* One more column than a directory row (tags) and the save button riding the
   end of the first row, so the booth rail still lines up down the list. */
.trade-row {
  grid-template-columns: minmax(0, 1fr) minmax(0, 110px) minmax(0, 230px) auto;
  align-items: baseline;
}
/* Tags run under the name on their own row; the save button is pinned to the
   end of the first row so the right rail stays a rail rather than the button
   being pushed below the tags. */
.trade-row .trade-tags {
  grid-column: 1 / -1;
  grid-row: 2;
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}
.trade-row > .bm { grid-column: 4; grid-row: 1; justify-self: end; }
.trade-row .trade-tags:empty { display: none; }
/* A count, not a verdict — how many exhibitors list this stand. */
.trade-shared {
  font-family: var(--font-mono);
  font-size: .66rem;
  letter-spacing: .05em;
  padding: 1px 6px;
  border: 1px solid color-mix(in srgb, var(--ok) 34%, var(--rule));
  border-radius: 999px;
  color: var(--ok);
  white-space: nowrap;
  cursor: help;
}
.trade-row .trade-tags .tag { font-size: .64rem; }
.trade-row[data-saved="true"] { background: color-mix(in srgb, var(--signal) 7%, transparent); }

@media (max-width: 640px) {
  .trade-row { grid-template-columns: minmax(0, 1fr) auto; }
  .trade-row .dir-country { grid-column: 1; grid-row: 2; }
  .trade-row .dir-stands { grid-column: 1; grid-row: 3; }
  .trade-row .trade-tags { grid-row: 4; }
  .trade-row > .bm { grid-column: 2; grid-row: 1; }
}

/* ---- curated trade cards ---- */

/* The Lineup block's opposite number: what the booth is *for*. */
.offers {
  list-style: none;
  margin: 0;
  padding: 0;
}
.offers li {
  padding: 7px 12px;
  border-top: 1px solid var(--rule-soft);
  font-size: .84rem;
  line-height: 1.45;
  color: var(--ink-mid);
}
.offers li:first-child { border-top: none; }
.offers li::before {
  content: "→";
  margin-right: 8px;
  color: var(--warn);
  font-family: var(--font-mono);
}

/* Where a consumer card puts the queue index. Same question, different
   answer: not "how long is the wait" but "is there anything to walk up to". */
.trade-access {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 2px 10px;
  align-items: baseline;
}
.trade-access-val {
  font-family: var(--font-mono);
  font-size: .78rem;
  letter-spacing: .04em;
  color: var(--warn);
  text-transform: uppercase;
}
.trade-access[data-access="open"] .trade-access-val { color: var(--ok); }
.trade-access-note {
  grid-column: 2;
  font-size: .78rem;
  line-height: 1.5;
  color: var(--ink-dim);
}

/* ============================================================
   saved & played marks
   ------------------------------------------------------------
   Saved is the visitor's own emphasis, so it takes the signal colour when set —
   that is what the one accent is for. Its "+" and "−" use the same language as
   "+ 4 more" above. Played deliberately does not take the signal colour: done
   is the opposite of emphasis, so its set plate and related content stay muted.
   ============================================================ */
.bm {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  padding: 0;
  background: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  color: var(--ink-dim);
  font-family: var(--font-mono);
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .12em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color .12s, border-color .12s, background .12s, opacity .12s;
}
.bm:hover { color: var(--ink); border-color: var(--ink-dim); }
.bm[aria-pressed="true"] {
  background: var(--signal);
  border-color: var(--signal);
  color: var(--signal-ink);
}
.bm[aria-pressed="true"]:hover { background: #ff6b3b; border-color: #ff6b3b; color: var(--signal-ink); }
.bm[data-mark="played"][aria-pressed="true"],
.bm[data-mark="played"][aria-pressed="true"]:hover {
  background: var(--ink-dim);
  border-color: var(--ink-dim);
  color: var(--ground);
}
.bm-mark { font-size: .9rem; line-height: 1; }

/* A mark button with its word beside its glyph rather than the glyph alone. */
.bm-wide { width: auto; height: auto; padding: 0 15px; }

/* --- the action corner: three rows, one per thing you can do with a booth ---

   It used to be two controls side by side, and the pair had two problems.
   The ✓ was a bare glyph with no word on it, so the one control on the card
   that needs explaining was the one that got no explanation; and the Save
   button sized itself to its own label, so pressing it — "Save" becoming
   "Saved", "Merken" becoming "Gemerkt" — moved the exhibitor's name under
   the thumb that had just pressed it.

   Stacked, they are one column of fixed width: every label fits, nothing
   reflows when a state changes, and there is room for a third row without
   taking any more of the name than the pair already did (the old pair ran
   about 138px wide against this column's 104). The head grows about 30px
   in exchange, which the hall plate spends on being a taller sign. */
.exh-actions {
  flex: 0 0 104px;
  display: flex;
  flex-direction: column;
  margin-left: auto;
  border-left: 1px solid var(--rule-soft);
}
.exh-actions .bm {
  flex: 1 1 0;
  width: auto;
  height: auto;
  min-height: 31px;
  justify-content: flex-start;
  gap: 7px;
  /* A third of a character tighter than the small caps everywhere else in
     the guide, which is what fits "Gespielt" — the longest of the eight
     labels the two languages between them put in these rows — inside the
     column without it having to grow into the exhibitor's name. */
  letter-spacing: .08em;
  padding: 0 11px;
  border: none;
  border-radius: 0;
  text-align: left;
}
.exh-actions .bm + .bm { border-top: 1px solid var(--rule-soft); }
/* The three glyphs line up in their own column, so the labels start at the
   same place whether the row is showing a "+", a "−" or a "✓". */
.exh-actions .bm-mark { flex: 0 0 11px; text-align: center; }
.exh-actions .bm-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The border is gone, so hover has to be carried by the fill. Set rows keep
   their own — an orange row does not get lighter for being pointed at. */
.exh-actions .bm:not([aria-pressed="true"]):hover { background: var(--ground-3); color: var(--ink); }
.card:hover .exh-actions .bm:not([aria-pressed="true"]):hover { background: var(--rule-soft); }

/* On the narrowest phones the exhibitor's name is down to a word a line, so
   the column gives back the eight pixels it can spare. The labels still fit:
   what goes is the air around them. */
@media (max-width: 380px) {
  .exh-actions { flex-basis: 96px; }
  .exh-actions .bm { padding: 0 9px; gap: 6px; font-size: .64rem; }
  /* And the name's own gutters come in with them, which is what keeps a
     short exhibitor name on one line at the width German needs most. */
  .exh-id { padding-left: 10px; padding-right: 10px; }
}
.card[data-saved="true"] { border-color: var(--signal); }
.card[data-played="true"] { border-color: var(--rule); }
.card[data-played="true"] .card-body { opacity: .62; }

/* Repeated down a 23-game lineup the buttons would out-shout the titles, so
   they sit back until you reach for one — or until they're set. */
.game .bm { width: 18px; height: 18px; align-self: center; opacity: .45; }
.game:hover .bm,
.game .bm:focus-visible,
.game .bm[aria-pressed="true"] { opacity: 1; }
.game[data-saved="true"] .game-title { color: var(--signal); }
.game[data-played="true"] .game-title,
.game[data-played="true"] .game-plat { color: var(--ink-dim); }
.game[data-played="true"]::before { background: var(--neutral); }

.saved-count {
  font-family: var(--font-mono);
  font-size: .72rem;
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}
.toggle input:checked ~ .saved-count { color: var(--signal); }

.tag-row { display: flex; gap: 5px; flex-wrap: wrap; }
.tag {
  font-family: var(--font-mono);
  font-size: .66rem;
  color: var(--ink-dim);
  border: 1px solid var(--rule);
  padding: 1px 7px;
  border-radius: var(--radius-sm);
}

/* --- foot: queue index gauge --- */
.card-foot {
  margin-top: auto;
  padding: 12px 16px;
  border-top: 1px solid var(--rule-soft);
  background: rgba(0, 0, 0, .18);
  display: flex;
  flex-direction: column;
  gap: 9px;
}
.queue { display: flex; align-items: center; gap: 12px; }
.queue-val {
  font-family: var(--font-mono);
  font-size: .72rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  font-variant-numeric: tabular-nums;
  margin-left: auto;
}
.meter { display: flex; gap: 2px; }
.meter i { width: 22px; height: 6px; background: var(--rule); border-radius: 1px; }
.meter[data-level="1"] i:nth-child(-n+1),
.meter[data-level="2"] i:nth-child(-n+2) { background: var(--ok); }
.meter[data-level="3"] i:nth-child(-n+3) { background: var(--warn); }
.meter[data-level="4"] i:nth-child(-n+4),
.meter[data-level="5"] i:nth-child(-n+5) { background: var(--alert); }

.queue[data-level="1"] .queue-val, .queue[data-level="2"] .queue-val { color: var(--ok); }
.queue[data-level="3"] .queue-val { color: var(--warn); }
.queue[data-level="4"] .queue-val, .queue[data-level="5"] .queue-val { color: var(--alert); }

/* --- live queue reports: measurements, never another forecast meter --- */
.queue { flex-wrap: wrap; }
.queue-summary { flex: 1 1 100%; min-width: 0; }
.queue-summary:empty,
.queue-plan-live:empty { display: none; }
/* Main's plan and route rows grew to six columns while this was out, and the
   live figure is not one of them — it is a measurement about the row rather
   than a field of it. Giving it a row of its own at every width keeps it out
   of that column maths entirely. */
.it-item > .queue-plan-live,
.route-item > .queue-summary { grid-column: 1 / -1; }
.queue-tracker {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}
.queue-tracker:empty { display: none; }
.game-queue {
  grid-column: 1 / -1;
  margin-top: 5px;
  padding-top: 8px;
  border-top: 1px dashed var(--rule-soft);
}
/* One control per card now, sitting with the forecast meter rather than under
   every game. Pushed to the end of the queue row so the reading (index, then
   live figure) comes before the doing. */
.queue-report {
  margin-left: auto;
  display: flex;
}
.queue-report:empty { display: none; }
.queue-report-link {
  font-family: var(--font-mono);
  font-size: .66rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-dim);
  text-decoration: none;
  white-space: nowrap;
  /* A 44px target without a 44px box: the row is already tight, so the reach
     is bought with padding that does not push the meter around. The rule below
     is what that padding cannot be allowed to move — a border-bottom would sit
     at the far edge of it, a dozen pixels under the text and into whatever
     follows. text-decoration hugs the glyphs however tall the box is, which is
     the same reason .subtle-link uses it. */
  padding: 10px 0;
  margin: -10px 0;
}
.queue-report-link:hover,
.queue-report-link:focus-visible {
  color: var(--ink);
  text-decoration: underline;
  text-decoration-color: var(--ink-dim);
  text-underline-offset: 3px;
}
.queue-report-link-active { color: var(--signal); }
.queue-live {
  display: flex;
  align-items: baseline;
  gap: 5px 8px;
  flex-wrap: wrap;
  min-width: 0;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}
.queue-live-main {
  color: var(--signal);
  font-size: .78rem;
  font-weight: 700;
}
.queue-live[data-tier="done"] .queue-live-main { color: var(--ok); }
.queue-live[data-tier="sofar"] .queue-live-main { color: var(--warn); }
.queue-live[data-tier="closed"] .queue-live-main { color: var(--alert); }
/* Yesterday's pooled figure — deliberately not a live colour. */
.queue-live[data-tier="typical"] .queue-live-main { color: var(--ink-mid); }
.queue-live-detail {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  flex-wrap: wrap;
  color: var(--ink-dim);
  font-size: .66rem;
}
.queue-live-unavailable {
  color: var(--warn);
  font-size: .7rem;
}
.queue-live-compact .queue-live-main { font-size: .7rem; white-space: nowrap; }
.queue-live-at {
  margin-left: 5px;
  font-family: var(--font-mono);
  font-size: .62rem;
  color: var(--ink-dim);
}
.queue-session,
.queue-start-actions,
.queue-session-actions {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}
.queue-session { justify-content: space-between; }
.queue-session-time {
  font-family: var(--font-mono);
  font-size: .76rem;
  font-weight: 700;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.queue-action,
.queue-choice,
.queue-dialog-done {
  min-height: 44px;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: none;
  color: var(--ink-mid);
  padding: 8px 11px;
  font: inherit;
  font-size: .76rem;
  font-weight: 700;
  cursor: pointer;
}
.queue-action:hover,
.queue-choice:hover,
.queue-dialog-done:hover { color: var(--ink); border-color: var(--ink-dim); }
.queue-action-primary,
.queue-choice.active {
  background: var(--signal);
  border-color: var(--signal);
  color: var(--signal-ink);
}
.queue-action:disabled,
.queue-choice:disabled { opacity: .5; cursor: wait; }
.queue-start-actions .queue-action,
.queue-session-actions .queue-action { min-height: 44px; padding-block: 6px; }

/* ============================================================
   live queues view
   ============================================================ */

.queues-notice {
  border: 1px solid var(--rule);
  border-left: 3px solid var(--warn);
  padding: 10px 13px;
  font-size: .82rem;
  color: var(--ink-mid);
  margin-bottom: 20px;
}
/* The off-show face of the tab. Bordered like the notice above it rather than
   left as loose paragraphs, because it is standing in for two whole sections
   and an unframed run of text reads as a page that failed to load. Its accent
   is the signal colour, not the warning one: nothing is wrong here, the show
   simply has not started. */
.queues-preview {
  border: 1px solid var(--rule);
  border-left: 3px solid var(--signal);
  padding: 16px 18px;
  max-width: 62ch;
}
.queues-preview p {
  font-size: .88rem;
  color: var(--ink-mid);
  line-height: 1.6;
}
.queues-preview p + p { margin-top: 12px; }
.queues-preview-when {
  color: var(--ink);
  font-weight: 600;
}

.queues-scope {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}
.queues-scope[hidden] { display: none; }
.queues-scope-label {
  font-family: var(--font-mono);
  font-size: .7rem;
  letter-spacing: .06em;
  color: var(--ink-mid);
}
/* The toolbar's search input is borderless because `.toolbar` draws the box
   around it. Standing on its own here, the field has to draw its own, or the
   input falls back to the browser's default chrome and looks like raw HTML
   next to everything else. */
.queues-field {
  max-width: 26rem;
  border: var(--border);
  border-radius: var(--radius);
  background: var(--ground-2);
  margin-bottom: 10px;
}
.queues-list { display: flex; flex-direction: column; }

/* A row is three parts that stack on a phone and spread on a wide screen:
   what the queue is, what it is doing, and what you can do about it. */
.queue-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: 8px;
  padding: 14px 0;
  border-top: 1px solid var(--rule-soft);
}
.queue-row:first-child { border-top: none; }
.queue-row-title {
  font-family: var(--font-display);
  font-size: 1rem;
  line-height: 1.15;
  margin: 0;
}
.queue-row-sub,
.queue-row-place {
  margin: 2px 0 0;
  font-size: .76rem;
  color: var(--ink-dim);
}
.queue-row-place { font-family: var(--font-mono); letter-spacing: .04em; }
.queue-row-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
/* The switch step's two buttons are sentences rather than chips, so they take
   the full width and stack instead of sitting in a row and wrapping badly. */
.queue-choice-wide { flex: 1 1 100%; text-align: left; }
.queue-switch-held { margin: .3rem 0 .9rem; color: var(--ink-mid); font-size: .9rem; }
/* Shorter than the shared 44px. These sit in a list where every row repeats
   them, so full-height buttons make the list a wall; the dialog's chips and
   the prompt bar keep the taller target, being one-off decisions. The 8px gap
   above buys back the separation the height was providing. */
.queue-row-actions .queue-action {
  min-height: 34px;
  padding: 5px 12px;
  font-size: .74rem;
}
.queue-row-actions:empty { display: none; }
.queue-live-none { font-size: .76rem; color: var(--ink-dim); }

@media (min-width: 40rem) {
  .queue-row {
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: start;
    column-gap: 20px;
  }
  .queue-row-live { grid-column: 2; grid-row: 1; text-align: right; justify-content: flex-end; }
  .queue-row-live .queue-live { justify-content: flex-end; }
  .queue-row-actions { grid-column: 2; grid-row: 2; justify-content: flex-end; }
}

.advice { font-size: .82rem; color: var(--ink-mid); line-height: 1.5; }
.advice-label {
  font-family: var(--font-mono);
  font-size: .62rem; letter-spacing: .14em; text-transform: uppercase;
  color: var(--signal); margin-right: 7px;
}

/* Leaving the guide is a deliberate act, so the link sits quiet under the
   plan and only lights up on hover — it must not compete with the queue gauge. */
.foot-links { display: flex; align-items: center; gap: 14px; }
.official-link {
  font-family: var(--font-mono);
  font-size: .68rem; letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-dim);
  text-decoration: none;
  border-bottom: 1px solid var(--rule);
  padding-bottom: 1px;
}
.official-link:hover { color: var(--signal); border-bottom-color: var(--signal); }

/* The attribution marker. It has to be there on every card without becoming
   the thing you read, so it sits at the far end of the foot in the quietest
   ink on the page — the count is the whole label, and the accessible name
   spells out what it opens. margin-left:auto rather than an order rule so it
   still lands on the right when a card has no official page to sit beside. */
.src-btn {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  margin-left: auto;
  flex: 0 0 auto;
  /* Quiet, but still a comfortable thumb target — the padding grows the box
     without adding any ink. */
  min-height: 24px;
  padding: 2px 7px 2px 5px;
  background: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  color: var(--ink-dim);
  font-family: var(--font-mono);
  font-size: .64rem;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: color .12s, border-color .12s;
}
.src-btn:hover { color: var(--ink); border-color: var(--ink-dim); }
.src-glyph {
  display: inline-grid;
  place-items: center;
  width: 13px; height: 13px;
  border: 1px solid currentColor;
  border-radius: 50%;
  font-size: .56rem;
  font-weight: 700;
  line-height: 1;
}

/* ============================================================
   planner — section jump nav
   ============================================================ */
/* Sits between the lede and section 01, eating into the lede's 32px so the
   row reads as part of the intro rather than a section of its own. */
.plan-jump { display: flex; gap: 6px; flex-wrap: wrap; margin: -12px 0 0; }
.plan-jump:empty { display: none; }
.jump-chip .section-num { font-size: .6rem; }
.jump-chip:hover .section-num { color: var(--signal); }

/* ============================================================
   planner — day board
   ============================================================ */
.day-board { border: var(--border); border-radius: var(--radius); overflow: hidden; }
.day-row {
  display: grid;
  grid-template-columns: 92px 130px 1fr;
  gap: 0 18px;
  align-items: baseline;
  padding: 14px 18px;
  border-bottom: 1px solid var(--rule-soft);
  background: var(--ground-2);
}
.day-row:last-child { border-bottom: none; }
.day-row:hover { background: var(--ground-3); }

.day-when { display: flex; flex-direction: column; }
.day-dow {
  font-family: var(--font-poster);
  font-size: 1.45rem; font-weight: 400; line-height: 1;
  text-transform: uppercase; letter-spacing: .01em;
}
.day-date {
  font-family: var(--font-mono);
  font-size: .7rem; color: var(--ink-dim); font-variant-numeric: tabular-nums;
}
.day-access {
  font-family: var(--font-mono);
  font-size: .64rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
  padding: 3px 8px; border-radius: var(--radius-sm); justify-self: start;
  border: 1px solid;
}
.day-access.public { color: var(--ok); border-color: rgba(47,191,133,.3); background: rgba(47,191,133,.08); }
.day-access.trade  { color: var(--alert); border-color: rgba(242,86,79,.3); background: rgba(242,86,79,.08); }

.day-detail { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.day-hours { font-family: var(--font-mono); font-size: .74rem; color: var(--ink); font-variant-numeric: tabular-nums; }
.day-note { color: var(--ink-mid); font-size: .85rem; }
/* The plan's per-day count, worn only by the five-days board (the plan's own
   day groups list their stops right below the header). Under the access
   badge, spanning the rest of the row. A button through to that day's stops
   in the plan, dressed like the .reset text-buttons; justify-self keeps the
   hit area on the words rather than the whole grid track. */
.day-planned {
  grid-column: 2 / -1;
  justify-self: start;
  margin-top: 6px;
  background: none;
  border: none;
  padding: 0;
  font-family: var(--font-mono);
  font-size: .66rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--signal);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* ============================================================
   planner — your plan, shared shell (see the lens blocks below)
   ============================================================ */
/* Every jump-nav target clears the sticky header — the plan's day groups
   included, which the day board's counts jump to; the mobile block below
   shrinks this once the header stops being sticky.

   --header-h is the masthead's measured height, published by app.js
   (trackHeaderHeight) because the masthead has no fixed one: the install
   button, the offline notice and the longer German strings each change it.
   The fallback is what a stock English desktop masthead measures, for the
   moment before the script runs. The 12px on top is breathing room — a
   heading flush against the header's edge reads as clipped even when it is
   whole.

   #trade is not a jump-nav target but is scrolled to the same way: the toast
   that turns trade mode on offers to show you the section it just filled.

   The row controls take the same offset, and a gutter off the bottom edge as
   well, because restoreFocus() (js/app.js) reveals one this way when a
   re-render has carried it off screen — and focus you cannot see is the thing
   that reveal exists to prevent, so landing it behind the masthead or flush
   against the bottom edge would be no reveal at all. */
#plan-section,
#plan-board .it-group,
#view-planner .section-title,
#trade { scroll-margin-top: calc(var(--header-h, 161px) + 12px); }
.bm, .mv, .day-chip, .src-btn { scroll-margin: calc(var(--header-h, 161px) + 12px) 0 24px; }
/* Centered in .empty's bottom padding, so message and action read as one
   block; invisible whenever the message is. */
#plan-browse { display: block; margin: -48px auto 60px; }
.plan-controls {
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  margin: -4px 0 12px;
}
.plan-controls .result-count { margin-left: auto; }
.plan-lens { display: flex; gap: 6px; }
.plan-days {
  display: flex;
  gap: 5px;
  flex-wrap: wrap;
  margin: -2px 0 12px;
}

/* The queue index, one cell in both lenses — crowdCell() writes it. It is the
   number the plan sorts itself by, so it belongs next to the stop in either
   arrangement, and it is the number a hand-arranged order is departing from.
   Colour only at the top of the scale: 4 and 5 are the ones worth queueing
   early for, and painting the calm end green would say the opposite. */
.route-crowd,
.it-crowd {
  font-family: var(--font-mono);
  font-size: .72rem;
  font-weight: 700;
  color: var(--ink-mid);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.route-crowd[data-level="4"], .it-crowd[data-level="4"] { color: var(--warn); }
.route-crowd[data-level="5"], .it-crowd[data-level="5"] { color: var(--alert); }

/* The two arrows that move a stop up or down its group. Same 20px square as
   the mark buttons they sit beside: these are row controls, not a second
   toolbar, and a row that shouted about its own position would be reading
   the plan backwards. */
.row-move { display: flex; gap: 4px; align-self: center; }
.row-actions .row-move { margin-right: 4px; }
.mv {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  padding: 0;
  background: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  color: var(--ink-dim);
  font-size: .58rem;
  line-height: 1;
  cursor: pointer;
  transition: color .12s, border-color .12s, background .12s, opacity .12s;
}
.mv:hover:not(:disabled) { color: var(--ink); border-color: var(--ink-dim); }
/* Disabled, not hidden: an end of the list is a fact about the row, and a
   control that vanished under the pointer would shift the two beside it. */
.mv:disabled { opacity: .28; cursor: default; }

/* ============================================================
   planner — your plan, day lens (itinerary)
   ============================================================ */
.it-board { border: var(--border); border-radius: var(--radius); overflow: hidden; }
.it-group + .it-group { border-top: 1px solid var(--rule); }
.it-group-head {
  display: grid;
  /* the fourth column is the day's "Map →", which only the plan's own day
     groups carry — the five-days board renders the same header without it
     and simply leaves the column empty */
  grid-template-columns: 92px 130px 1fr auto;
  gap: 0 18px;
  align-items: baseline;
  padding: 11px 18px;
  background: var(--ground-3);
  border-bottom: 1px solid var(--rule);
}
/* Placed rather than flowed, so the phone layout below can fold the hours
   onto their own line underneath without the chip following them down. */
.day-map { grid-row: 1; grid-column: -2 / -1; justify-self: end; align-self: center; }
.it-group-head.unassigned { display: block; }
.it-group-title {
  font-family: var(--font-mono);
  font-size: .7rem;
  font-weight: 700;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--ink-mid);
}

.it-item {
  display: grid;
  grid-template-columns: minmax(140px, 1.1fr) minmax(150px, 1.2fr) auto auto auto 20px;
  gap: 0 14px;
  align-items: center;
  padding: 12px 18px;
  border-bottom: 1px solid var(--rule-soft);
  background: var(--ground-2);
}
.it-item:last-child { border-bottom: none; }
.it-item:hover { background: var(--ground-3); }
.it-item[data-played="true"] {
  opacity: .62;
  box-shadow: inset 3px 0 0 var(--ink-dim);
}
.it-main { display: flex; flex-direction: column; min-width: 0; }
.it-kind {
  font-family: var(--font-mono);
  font-size: .62rem;
  font-weight: 500;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ink-dim);
}
.it-name { font-weight: 700; font-size: .95rem; }
.it-loc {
  font-family: var(--font-mono);
  font-size: .72rem;
  color: var(--ink-mid);
  font-variant-numeric: tabular-nums;
}
.it-days { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
.day-chip {
  background: none;
  border: 1px solid var(--rule);
  color: var(--ink-mid);
  border-radius: var(--radius-sm);
  padding: 3px 8px;
  font-family: var(--font-mono);
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color .12s, border-color .12s, background .12s;
}
.day-chip:hover { color: var(--ink); border-color: var(--ink-dim); }
.day-chip.active { background: var(--signal); border-color: var(--signal); color: var(--signal-ink); }
.day-chip[data-trade="true"]:not(.active) { border-style: dashed; color: var(--ink-dim); }
/* A day the business area is shut, offered to a business-area stop. Struck
   through rather than disabled: the guide warns, it does not forbid — you may
   well want to walk past a stand on Saturday. */
.day-chip[data-closed="true"]:not(.active) { color: var(--ink-dim); text-decoration: line-through; }
.day-chip[data-closed="true"].active {
  background: var(--warn);
  border-color: var(--warn);
  color: var(--signal-ink);
}
/* Part of this stop is on that day and part of it is not — a booth where you
   saved two games and put one of them on Thursday. Washed rather than filled:
   pressing it pulls the rest across, so it must not already read as done. */
.day-chip[data-partial="true"] {
  background: var(--signal-wash);
  border-color: var(--signal);
  color: var(--signal);
}
.it-item .bm { grid-column: -2 / -1; grid-row: 1; align-self: center; }

/* The consequence spelled out, on the row and once at the top of the day. */
.it-warn {
  grid-column: 1 / -1;
  margin-top: 8px;
  padding-left: 11px;
  border-left: 2px solid var(--warn);
  font-size: .76rem;
  line-height: 1.45;
  color: var(--warn);
}
.it-group-warn {
  margin: 0;
  padding: 9px 18px;
  border-bottom: 1px solid var(--rule-soft);
  background: color-mix(in srgb, var(--warn) 10%, var(--ground-2));
  font-size: .78rem;
  color: var(--warn);
}

/* ============================================================
   planner — priority table
   ============================================================ */
.priority-controls {
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}
.priority-controls .result-count { margin-left: auto; }

.priority-table { border: var(--border); border-radius: var(--radius); overflow: hidden; }
.priority-item {
  display: grid;
  grid-template-columns: 40px minmax(150px, 1.1fr) 96px minmax(180px, 2fr) minmax(105px, auto) auto;
  gap: 0 16px;
  align-items: baseline;
  padding: 12px 18px;
  border-bottom: 1px solid var(--rule-soft);
  background: var(--ground-2);
}
/* the last column in either layout, so no override is needed on a phone */
.row-actions {
  display: flex;
  align-items: center;
  gap: 6px;
}
.priority-item .row-actions { grid-column: -2 / -1; grid-row: 1; align-self: center; }
.priority-item[data-saved="true"] { box-shadow: inset 3px 0 0 var(--signal); }
.priority-item[data-played="true"] {
  opacity: .62;
  box-shadow: inset 3px 0 0 var(--ink-dim);
}

/* Which of your saved games this booth is actually running — the reason a
   publisher row is on your list at all. */
.priority-saved {
  grid-column: 2 / -1;
  display: flex;
  align-items: baseline;
  gap: 6px;
  flex-wrap: wrap;
  margin-top: 5px;
}
.priority-saved .row-label { color: var(--signal); flex: 0 0 auto; }
.priority-game {
  font-family: var(--font-mono);
  font-size: .72rem;
  color: var(--ink);
  border: 1px solid var(--signal-wash);
  background: var(--signal-wash);
  padding: 1px 7px;
  border-radius: var(--radius-sm);
}
.priority-item:last-child { border-bottom: none; }
.priority-item:hover { background: var(--ground-3); }
.priority-rank {
  font-family: var(--font-mono);
  font-weight: 700; font-size: .95rem;
  color: var(--signal);
  font-variant-numeric: tabular-nums;
}
.priority-name,
.wristband-name { font-weight: 700; font-size: .95rem; }
.priority-name .badge-age { margin-left: 6px; vertical-align: .1em; }
.priority-loc,
.wristband-loc {
  font-family: var(--font-mono);
  font-size: .76rem; color: var(--ink-mid);
  font-variant-numeric: tabular-nums;
}
.priority-advice,
.wristband-games { color: var(--ink-mid); font-size: .84rem; }

/* Same row language as queue priority, without the rank column. Four tracks, and
   every row fills all four — the "18+ expected" marker rides inside the titles
   cell, because each row is its own grid and a column only some rows fill would
   size their fr tracks differently. */
.wristband-item {
  grid-template-columns: minmax(150px, 1.1fr) minmax(130px, .8fr) minmax(200px, 2fr) auto;
}
.wristband-status { margin-left: 4px; vertical-align: baseline; }

/* ============================================================
   planner — your plan, hall lens (route)
   ============================================================ */
.route-board { border: var(--border); border-radius: var(--radius); overflow: hidden; }
.route-hall {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 9px 18px;
  border-bottom: 1px solid var(--rule-soft);
  background: var(--ground-3);
}
.route-hall-kicker,
.route-hall-steps {
  font-family: var(--font-mono);
  font-size: .64rem;
  font-weight: 500;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ink-dim);
}
.route-hall-num {
  font-family: var(--font-display);
  font-size: 1.25rem;
  font-weight: 700;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.route-hall-map {
  font-family: var(--font-mono);
  font-size: .64rem; letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-dim); text-decoration: none;
  border: 1px solid var(--rule); border-radius: var(--radius-sm);
  padding: 2px 7px;
}
.route-hall-map:hover, .route-hall-map:focus-visible { color: var(--ink); border-color: var(--ink-dim); }
/* "you're here" on a heading is status, not advice, so it takes the ok green
   and never the signal orange — the one orange thing stays the stop you have
   not walked to yet. */
.route-hall-here {
  font-family: var(--font-mono);
  font-size: .64rem;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ok);
}
.route-hall-count {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: .68rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}

.route-item {
  display: grid;
  grid-template-columns: minmax(150px, 1.2fr) minmax(110px, 1fr) auto minmax(105px, auto) auto;
  gap: 0 16px;
  align-items: baseline;
  padding: 12px 18px;
  border-bottom: 1px solid var(--rule-soft);
  background: var(--ground-2);
}
.route-item:last-child { border-bottom: none; }
.route-item:hover { background: var(--ground-3); }
.route-item[data-saved="true"] { box-shadow: inset 3px 0 0 var(--signal); }
.route-item[data-played="true"] {
  opacity: .62;
  box-shadow: inset 3px 0 0 var(--ink-dim);
}
.route-deadline { grid-column: 4; grid-row: 1; }
.route-item .row-actions { grid-column: -2 / -1; grid-row: 1; align-self: center; }
.route-item .priority-saved { grid-column: 1 / -1; }
/* A line of its own under the stop, left-aligned: the row above it is already
   five columns of address, queue and controls, and the chips are the widest
   thing on it. Left rather than right, unlike the day lens, because here they
   sit under the name they belong to rather than in a column of their own. */
.route-item .route-days { grid-column: 1 / -1; justify-content: flex-start; margin-top: 4px; }
.route-name { font-weight: 700; font-size: .95rem; }
.route-booth {
  font-family: var(--font-mono);
  font-size: .76rem;
  color: var(--ink-mid);
  font-variant-numeric: tabular-nums;
}
.route-absent {
  margin-top: 12px;
  color: var(--ink-dim);
  font-size: .82rem;
}

/* ============================================================
   preview bar — ?now= moved the clock
   ============================================================ */
/* Deliberately the loudest thing on the page and deliberately not --signal:
   this is not the visitor's own emphasis, it is the page admitting it is
   lying about the date. Warn ink on a warn wash, full bleed, above the
   masthead. It scrolls away with the page, which is right — it is a
   development affordance, not a standing alarm. */
.preview-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px 12px;
  padding: 7px max(16px, env(safe-area-inset-left));
  background: color-mix(in srgb, var(--warn) 16%, var(--ground));
  border-bottom: 1px solid color-mix(in srgb, var(--warn) 40%, var(--ground));
  font-family: var(--font-mono);
  font-size: .72rem;
  color: var(--warn);
}
.preview-tag {
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
}
.preview-when { color: var(--ink-mid); font-variant-numeric: tabular-nums; }
.preview-exit {
  margin-left: auto;
  position: relative;
  color: var(--warn);
  text-decoration: underline;
  text-underline-offset: 3px;
}
/* The same treatment the footer links got: 18px of mono type is a link to
   read, not a link to tap, and this one is the way back out of a page
   claiming to be a day it is not. It reaches 28px without moving, which is
   past the 24 a fingertip wants. The sides stop at half the bar's 12px gap,
   and both of its neighbours are plain text anyway, so nothing here can take
   a tap meant for something else. */
.preview-exit::after { content: ""; position: absolute; inset: -5px -6px; }
.preview-exit:hover, .preview-exit:focus-visible { color: var(--ink); }

/* ============================================================
   today
   ============================================================ */
/* The show-day mode. It reuses the hall lens's board wholesale — same
   .route-board, same rows — because it *is* the walking route, scoped to one
   day. Only the header above it and the fold below are new.

   One rule carries through from played tracking: nothing that measures what
   is *done* takes --signal. The meter and the Done fold are muted; the one
   orange thing on the screen is the stop you have not walked to yet. */
.today-strip {
  display: grid;
  grid-template-columns: auto auto 1fr;
  gap: 0 18px;
  align-items: center;
  padding: 16px 18px;
  border: var(--border);
  border-radius: var(--radius);
  background: var(--ground-2);
}
.today-when { display: flex; flex-direction: column; }
.today-dow {
  font-family: var(--font-poster);
  font-size: 1.75rem; font-weight: 400; line-height: 1;
  text-transform: uppercase; letter-spacing: .01em;
}
.today-date {
  font-family: var(--font-mono);
  font-size: .7rem; color: var(--ink-dim); font-variant-numeric: tabular-nums;
}
.today-status {
  font-family: var(--font-mono);
  font-size: .74rem;
  letter-spacing: .04em;
  color: var(--ink-mid);
  font-variant-numeric: tabular-nums;
  text-align: right;
}
/* Open is the only state worth a colour — it is the one that runs out. */
.today-strip[data-state="open"] .today-status { color: var(--ok); }
.today-strip[data-state="after"] .today-status { color: var(--ink-dim); }

.today-progress { margin: 14px 0 24px; }
.today-count,
.today-fit {
  font-family: var(--font-mono);
  font-size: .68rem; font-weight: 700;
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-mid);
  margin-bottom: 7px;
}
.today-meter {
  height: 4px;
  background: var(--rule-soft);
  border-radius: var(--radius-sm);
  overflow: hidden;
}
/* Muted, never orange: this bar measures what is behind you. */
.today-meter-fill { display: block; height: 100%; background: var(--ink-mid); }

/* The one piece of advice Today gives, so it gets the emphasis colour the
   done-counters deliberately do not. */
.today-first {
  margin-bottom: 16px;
  padding: 10px 0 10px 13px;
  border-left: 3px solid var(--signal);
  font-size: .88rem;
  line-height: 1.5;
  color: var(--ink-mid);
}
.today-first strong { color: var(--ink); }
.today-first-label {
  font-family: var(--font-mono);
  font-size: .64rem; font-weight: 700;
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--signal);
  margin-right: 8px;
}
/* With a position set the strip is up to three of these — one block, so the
   accent edge reads as one piece of advice with facets, not three alerts. */
.today-first-line { display: block; }
.today-first-line + .today-first-line { margin-top: 3px; }

/* The "where are you?" row — the hall picker, and the statement it folds to.
   The chips are the plan's own day chips; only the words around them are new. */
.today-here {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin: 12px 0 0;
}
.today-here-q,
.today-here-in {
  font-family: var(--font-mono);
  font-size: .68rem; font-weight: 700;
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-mid);
  margin-right: 4px;
}
.today-here-in { color: var(--ok); }
.today-here-change {
  font-family: var(--font-mono);
  font-size: .68rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  background: none; border: none; padding: 0;
  color: var(--ink-dim);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}
.today-here-change:hover, .today-here-change:focus-visible { color: var(--ink); }
/* Same warning voice as the plan's closed-day note, one level up the page. */
.today-warn {
  margin-bottom: 16px;
  padding: 9px 13px;
  border-left: 3px solid var(--warn);
  background: color-mix(in srgb, var(--warn) 10%, var(--ground-2));
  font-size: .8rem;
  color: var(--warn);
}

.today-done { margin-top: 18px; }
.today-done-summary {
  cursor: pointer;
  list-style: none;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: .66rem; font-weight: 700;
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--ink-dim);
  padding: 6px 0;
}
.today-done-summary::-webkit-details-marker { display: none; }
.today-done-summary::after {
  content: "▾";
  font-size: .7rem;
  transition: transform .12s;
}
.today-done[open] .today-done-summary::after { transform: rotate(180deg); }
.today-done-summary:hover { color: var(--ink); }
.today-done .route-board { margin-top: 8px; }

.today-unplaced {
  margin-top: 18px;
  font-size: .82rem;
  color: var(--ink-dim);
}
.today-actions { display: flex; gap: 18px; margin-top: 20px; }

/* ============================================================
   planner — tips
   ============================================================ */
.tips { list-style: none; counter-reset: tip; border-top: 1px solid var(--rule); }
.tips li {
  counter-increment: tip;
  position: relative;
  padding: 13px 0 13px 46px;
  border-bottom: 1px solid var(--rule-soft);
  color: var(--ink-mid);
  font-size: .9rem;
}
.tips li::before {
  content: counter(tip, decimal-leading-zero);
  position: absolute; left: 0; top: 13px;
  font-family: var(--font-mono);
  font-size: .74rem; font-weight: 700;
  color: var(--signal);
}

/* ============================================================
   event info
   ============================================================ */
.info-block { margin-bottom: 34px; }
.info-block > h2 {
  font-family: var(--font-poster);
  font-size: 1.35rem; font-weight: 400;
  text-transform: uppercase; letter-spacing: .01em;
  padding-bottom: 8px; margin-bottom: 14px;
  border-bottom: 1px solid var(--rule);
  display: flex; align-items: baseline; gap: 12px;
}
.info-block > h2 .section-num { color: var(--signal); }
.info-block p {
  color: var(--ink-mid);
  font-size: .9rem;
}
.info-block p + p { margin-top: 10px; }
.info-block b, .info-block strong { color: var(--ink); font-weight: 600; }
.info-block a { color: var(--ink); text-decoration: underline; text-decoration-color: var(--signal); text-underline-offset: 3px; }
.info-block a:hover { color: var(--signal); }

/* The "Map →" plate on the Halls & areas heading — the same chip the plan's
   hall and day headings wear, so the one gesture reads the same wherever the
   guide offers it. It borrows .route-hall-map for the box and restates only
   what `.info-block a` above would repaint: that rule matches it just as
   specifically and comes first, so an underlined body link is what it gets
   otherwise. Same reason a.area-hall below is spelt out, same fix.

   `flex: none` because the heading does not wrap: without it a long title in
   German squeezes the chip until "Map →" breaks across two lines instead of
   the words beside it wrapping, which is what should give. */
a.info-map {
  flex: none;
  margin-left: auto;
  white-space: nowrap;
  color: var(--ink-dim);
  text-decoration: none;
}
a.info-map:hover,
a.info-map:focus-visible { color: var(--ink); border-color: var(--ink-dim); }

.info-block .headline-fact {
  font-family: var(--font-poster);
  font-size: 2.1rem; font-weight: 400; line-height: 1.1;
  text-transform: uppercase; letter-spacing: .005em;
  color: var(--ink);
  margin-bottom: 6px;
}
.info-block .fact-sub {
  font-family: var(--font-mono);
  font-size: .78rem; color: var(--ink-dim); letter-spacing: .04em;
}
/* The same switch as the toolbar's, offered again where the other badge and
   access facts live — the semantically right home for a setting that
   describes you rather than the list, and reachable from any view. */
#badge-toggle,
#power-toggle { margin-top: 14px; }
/* Same pill as .trade-enable so the two states read as one control, in plain
   ink rather than amber — turning it off is not the offer being made. */
.badge-off {
  padding: 10px 16px;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: none;
  color: var(--ink-mid);
  font-family: var(--font-mono);
  font-size: .76rem;
  letter-spacing: .06em;
  text-transform: uppercase;
  cursor: pointer;
}
.badge-off:hover { border-color: var(--ink-dim); color: var(--ink); }

.area-list { list-style: none; border-top: 1px solid var(--rule-soft); }
.area-list li {
  display: grid;
  grid-template-columns: 76px 1fr;
  gap: 0 16px;
  padding: 11px 0;
  border-bottom: 1px solid var(--rule-soft);
  align-items: baseline;
}
.area-hall {
  font-family: var(--font-mono);
  font-size: .8rem; font-weight: 700; color: var(--signal);
  font-variant-numeric: tabular-nums;
}
.area-hall.none { color: var(--ink-dim); font-weight: 400; }
/* A hall the map can draw makes its plate the link to it (renderEvent). It
   stays a plate — same colour, same figures — and carries the quiet
   underline every other hall link in the guide wears. Spelt out rather than
   left to .hall-link because `.info-block a` above outranks that and would
   repaint the number as body text.

   The underline is the accent held back rather than .hall-link's --rule:
   these rows are already in the accent, and a rule-grey line under an
   orange number on a near-black ground is invisible. Browsers without
   color-mix fall through to the signal underline .info-block a set — a
   louder line, never a missing one. */
a.area-hall {
  /* A grid item is blockified, so without this the anchor spans its whole
     column — the full row width once the list goes single-column on a
     phone, where every tap in the empty half of that line would navigate.
     The hit area stays the number, padded out below. */
  justify-self: start;
  position: relative;
  color: var(--signal);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--signal) 45%, transparent);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
a.area-hall:hover,
a.area-hall:focus-visible { text-decoration-color: currentColor; }
/* Hall "5" sets eight pixels wide, which is a plate to read and not a plate
   to hit — a fingertip covers three times that. The number keeps its size and
   its place in the column (moving it would break the plates' alignment down
   the list, which is half of what makes them scannable); an invisible box
   around it takes the tap, at 24×24 for the narrowest of them.

   Only these plates, and only because they are plates. A hall number inside a
   planner row is a phrase in a line of text, where the line's own height is
   the constraint and a padded-out box would start swallowing the taps meant
   for the row it sits in. */
a.area-hall::after { content: ""; position: absolute; inset: -6px -8px; }
.area-name { font-weight: 600; font-size: .92rem; }
.area-desc { color: var(--ink-mid); font-size: .85rem; }

/* Entrance names are words, not hall numbers — drop the tabular figures and
   let "South" sit in the same plate column without being clipped. */
.entrance-name { font-variant-numeric: normal; text-transform: uppercase; letter-spacing: .03em; }
.info-block .entrance-trade {
  margin-top: 14px;
  border-left: 2px solid var(--signal);
  padding-left: 12px;
}

.link-list { list-style: none; border-top: 1px solid var(--rule-soft); }
.link-list li { border-bottom: 1px solid var(--rule-soft); }
.link-list a { display: flex; gap: 12px; align-items: baseline; padding: 11px 0; text-decoration: none; color: var(--ink); }
.link-list a:hover .link-name { color: var(--signal); }
.link-name { font-weight: 600; font-size: .92rem; }
.link-desc { color: var(--ink-dim); font-size: .82rem; margin-left: auto; }

/* ============================================================
   updates timeline
   ============================================================ */
.updates-toolbar {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  margin-bottom: 24px;
}
.chip.kind-chip.active { background: var(--signal); border-color: var(--signal); color: var(--signal-ink); }

.timeline { border-left: 1px solid var(--rule); padding-left: 26px; margin-left: 6px; }
.timeline-entry { position: relative; padding-bottom: 30px; }
.timeline-entry::before {
  content: "";
  position: absolute; left: -31px; top: 7px;
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--rule); border: 2px solid var(--ground);
}
/* Filled for the newest revision, and keyed on that rather than on being the
   first entry drawn: with the kind filter on, the first entry drawn is the
   newest one of *that kind*, and a lit dot there would say "latest revision"
   about a revision three weeks old. */
.timeline-entry[data-latest="true"]::before { background: var(--signal); }
.timeline-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 12px; margin-bottom: 8px; }
.timeline-date { font-family: var(--font-mono); font-size: .88rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.rev-tag,
.kind-tag,
.change-tag {
  font-family: var(--font-mono);
  font-size: .62rem; font-weight: 500; letter-spacing: .12em; text-transform: uppercase;
  padding: 1px 7px; border-radius: var(--radius-sm);
  border: 1px solid transparent;
  white-space: nowrap;
}
/* The revision number is the entry's name, not a signal, and it gave up the
   orange when the kind tags arrived: colour in this head now means what kind
   of work the revision was, and two orange plates side by side would have
   said it twice with two different meanings. */
.rev-tag { color: var(--ink-dim); border-color: var(--rule); }

/* Three kinds told apart on the palette that is already here rather than on a
   fourth hue: the green/amber/red run is severity throughout this app (queue
   levels, crowd levels, unconfirmed data) and cyan/purple is which area a hall
   is in, so borrowing either would have a tag claiming a meaning that is taken.
   What is left is the signal orange, spent as it is everywhere else on "the
   guide does something new", and two steps of the ink ramp. Content takes the
   bright one because it is the kind people come to this page looking for — new
   game information — and a fix, which changes nothing about the show, takes the
   quiet one. */
.kind-tag[data-kind="feature"],
.change-tag[data-kind="feature"] { color: var(--signal); border-color: var(--signal-wash); background: var(--signal-wash); }
.kind-tag[data-kind="content"],
.change-tag[data-kind="content"] { color: var(--ink); border-color: var(--rule); background: var(--ground-3); }
.kind-tag[data-kind="fix"],
.change-tag[data-kind="fix"] { color: var(--ink-dim); border-color: var(--rule-soft); background: var(--ground-2); }

.timeline-entry ul { list-style: none; }
.timeline-entry li {
  color: var(--ink-mid); font-size: .88rem;
  padding: 4px 0; position: relative;
}
/* The tag replaces the em-dash this list used to lead each bullet with — the
   line still opens with a mark at the same left edge, and it now says
   something. Untagged bullets keep the dash: see changeParts(). */
.timeline-entry li:not([data-kind]) { padding-left: 16px; }
.timeline-entry li:not([data-kind])::before {
  content: "—"; position: absolute; left: 0; color: var(--ink-dim);
}
.change-tag { margin-right: 9px; position: relative; top: -1px; }

/* Exhibitors and games named in a bullet, linked to their cards (see
   changeMarkup). The bullets are set in --ink-mid, so a name coming up to
   --ink is the whole highlight — it reads as the proper noun it is without a
   second colour in a list that already carries three kind tags. The underline
   is the quiet one .hall-link uses in the planner rows, for the same reason:
   these sit several to a line, and an affordance per name would be a rash. */
.change-ref {
  color: var(--ink);
  font-weight: 600;
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
.change-ref:hover,
.change-ref:focus-visible { color: var(--signal); text-decoration-color: currentColor; }

/* ============================================================
   footer
   ============================================================ */
.site-footer { border-top: 1px solid var(--rule); padding: 32px 0 48px; }
.footer-inner {
  display: grid;
  grid-template-columns: 1.4fr 1fr;
  gap: 48px;
  align-items: start;
}
.footer-col { display: flex; flex-direction: column; gap: 6px; font-size: .82rem; color: var(--ink-dim); }
.footer-strong { color: var(--ink); font-weight: 600; }
.mono-note { font-family: var(--font-mono); font-size: .7rem; line-height: 1.6; margin-top: 4px; }

.legend { list-style: none; display: flex; flex-direction: column; gap: 5px; margin-top: 4px; }
.legend li { display: flex; align-items: baseline; gap: 8px; font-size: .78rem; }
.legend b { color: var(--ink); font-weight: 600; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--neutral); flex: 0 0 auto; }
.dot[data-status="confirmed"] { background: var(--ok); }
.dot[data-status="expected"]  { background: var(--warn); }
.dot[data-status="rumored"]   { background: var(--neutral); }

.empty { text-align: center; color: var(--ink-dim); padding: 70px 0; font-size: .9rem; }
.empty code { font-family: var(--font-mono); color: var(--ink); }

.subtle-link {
  color: var(--ink-dim);
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-underline-offset: 3px;
}

/* ============================================================
   sheets — the panel chrome shared by every dialog
   ============================================================ */
.sheet {
  width: min(540px, calc(100vw - 32px));
  max-height: min(760px, calc(100vh - 32px));
  margin: auto;
  padding: 0;
  overflow: auto;
  background: var(--ground-2);
  color: var(--ink);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: var(--plate-shadow-hover);
}
.sheet::backdrop { background: rgba(0, 0, 0, .78); }

.sheet-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  padding: 18px 20px;
  border-bottom: 1px solid var(--rule);
}
.sheet-head h2 {
  margin-top: 2px;
  font-family: var(--font-poster);
  font-size: 1.65rem;
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: .005em;
  text-transform: uppercase;
}
.sheet-close {
  flex: 0 0 auto;
  width: 36px;
  height: 36px;
  background: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  color: var(--ink-dim);
  font: inherit;
  font-size: 1.35rem;
  line-height: 1;
  cursor: pointer;
}
.sheet-close:hover { border-color: var(--ink-dim); color: var(--ink); }

.sheet-body { padding: 20px; }

/* One reusable report sheet serves every game and booth queue. */
.queue-dialog { width: min(620px, calc(100vw - 32px)); }
.queue-dialog-subject {
  margin-bottom: 16px;
  color: var(--ink);
  font-weight: 700;
}
.queue-dialog-flow { display: flex; flex-direction: column; gap: 16px; }
.queue-dialog-step { display: flex; flex-direction: column; gap: 9px; }
.queue-dialog-question { color: var(--ink-mid); font-size: .88rem; }
.queue-choice-row { display: flex; gap: 7px; flex-wrap: wrap; }
.queue-choice { min-width: 70px; }
.queue-details {
  margin-top: 5px;
  padding-top: 13px;
  border-top: 1px solid var(--rule-soft);
}
.queue-details summary {
  color: var(--signal);
  font-weight: 700;
  cursor: pointer;
}
.queue-details > p { margin: 9px 0; color: var(--ink-dim); font-size: .8rem; }
.queue-details .queue-choice-row + .queue-dialog-question { margin-top: 12px; }
.queue-dialog-status {
  min-height: 1.5em;
  margin-top: 14px;
  color: var(--ink-mid);
  font-size: .8rem;
}
.queue-dialog-done { margin-top: 8px; background: var(--ink); color: var(--ground); }

/* The refocus nudge is separate from the one-action toast. It appears without
   moving focus and keeps all three outcomes reachable with one hand. */
.queue-prompt {
  position: fixed;
  z-index: 90;
  left: max(16px, env(safe-area-inset-left));
  right: max(16px, env(safe-area-inset-right));
  bottom: calc(16px + env(safe-area-inset-bottom));
  width: min(680px, calc(100vw - 32px));
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 14px;
  background: var(--ground-2);
  border: 1px solid var(--rule);
  border-left: 3px solid var(--signal);
  border-radius: var(--radius);
  box-shadow: var(--plate-shadow);
}
.queue-prompt[hidden] { display: none; }
.queue-prompt-copy { flex: 1 1 220px; min-width: 0; }
.queue-prompt-copy p { margin-top: 2px; font-size: .84rem; }
.queue-prompt-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.queue-prompt .queue-action { min-height: 44px; }
body.queue-prompt-open .toast { bottom: calc(112px + env(safe-area-inset-bottom)); }

/* ============================================================
   share saved list
   ============================================================ */
.share-count { margin-bottom: 14px; color: var(--ink-mid); font-size: .88rem; }

/* Mode and part rows — plain fieldsets wearing the toolbar's toggle look. */
.share-set {
  border: none;
  margin: 0 0 12px;
  padding: 0;
  display: flex;
  gap: 6px 18px;
  flex-wrap: wrap;
  align-items: center;
}
.share-set legend.field-label { padding: 0; margin-bottom: 6px; width: 100%; float: left; }
.share-set .toggle input:disabled { cursor: default; }
.share-set .toggle:has(input:disabled) { cursor: default; }
/* Only an *empty* part goes dim — the saved box is disabled because it always
   rides, not because it has nothing to say. */
.share-set .toggle:has(input:disabled:not(:checked)) { opacity: .45; }

.share-link-field {
  display: block;
  background: var(--ground);
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
}
.share-link-field .field-label { display: block; padding: 8px 12px 0; }
.share-link-field input {
  display: block;
  width: 100%;
  padding: 4px 12px 10px;
  background: none;
  border: none;
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: .74rem;
  line-height: 1.5;
}
.share-link-field input:focus-visible { outline-offset: -2px; }
.share-link-field input::selection { background: var(--signal); color: var(--signal-ink); }

.share-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.share-action {
  padding: 7px 13px;
  background: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: .7rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  cursor: pointer;
  transition: background .12s, border-color .12s, color .12s;
}
.share-action:hover { border-color: var(--ink-dim); }
.share-action-primary {
  background: var(--signal);
  border-color: var(--signal);
  color: var(--signal-ink);
}
.share-action-primary:hover { background: #ff6b3b; border-color: #ff6b3b; }
.share-action[hidden] { display: none; }
.share-status {
  margin-top: 10px;
  min-height: 1.2em;
  color: var(--ink-mid);
  font-size: .8rem;
}

.share-qr {
  display: grid;
  grid-template-columns: minmax(180px, 250px) 1fr;
  align-items: center;
  gap: 18px;
  margin-top: 20px;
  padding-top: 20px;
  border-top: 1px solid var(--rule-soft);
}
.share-qr[hidden], .share-qr-fallback[hidden] { display: none; }
.share-qr-image {
  width: 100%;
  padding: 8px;
  background: #fff;
  color: #000;
  line-height: 0;
}
.share-qr-image svg { display: block; width: 100%; height: auto; }
.share-qr p, .share-qr-fallback { color: var(--ink-dim); font-size: .8rem; }
.share-qr-fallback {
  margin-top: 20px;
  padding: 12px 14px;
  border-left: 3px solid var(--warn);
  background: rgba(232, 163, 58, .08);
}

/* Sharing the guide itself leads with the code instead of trailing it: the
   whole point is holding the screen out for a stranger's camera, so it gets
   the top of the sheet and a size you can read across a queue. */
.share-qr-solo {
  grid-template-columns: 1fr;
  justify-items: center;
  gap: 12px;
  margin: 0 0 18px;
  padding-top: 0;
  border-top: none;
}
.share-qr-solo .share-qr-image { max-width: 260px; }
.share-qr-solo p { text-align: center; max-width: 34ch; }

/* One booth's sheet says which booth before it says anything else — the name
   set like the sources sheet's subject, because it is answering the same
   question about which of a hundred-odd cards this is, and the hall and
   stand under it in the plate's own words. */
.booth-share-where {
  margin: 4px 0 16px;
  color: var(--ink-mid);
  font-size: .84rem;
}

/* ============================================================
   sources dialog
   ============================================================ */
.src-dialog { width: min(500px, calc(100vw - 32px)); }
.src-subject {
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 700;
  line-height: 1.25;
}
.src-note { margin-top: 4px; color: var(--ink-mid); font-size: .84rem; }

.src-list { list-style: none; margin-top: 16px; border-top: 1px solid var(--rule-soft); }
.src-list li {
  display: flex;
  align-items: baseline;
  gap: 12px;
  border-bottom: 1px solid var(--rule-soft);
}
.src-num {
  font-family: var(--font-mono);
  font-size: .66rem;
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
  padding: 11px 0;
}
/* The host is the part that tells you who is making the claim, so it is set as
   the link; the path trails behind it in dimmer ink and is allowed to wrap
   rather than pushing the panel sideways. */
.src-list a {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 2px 8px;
  padding: 10px 0;
  text-decoration: none;
  color: var(--ink);
}
.src-host {
  font-family: var(--font-mono);
  font-size: .78rem;
  border-bottom: 1px solid var(--rule);
}
.src-list a:hover .src-host { color: var(--signal); border-bottom-color: var(--signal); }
.src-path {
  min-width: 0;
  color: var(--ink-dim);
  font-family: var(--font-mono);
  font-size: .68rem;
  line-height: 1.5;
  overflow-wrap: anywhere;
}
.src-caveat {
  margin-top: 18px;
  padding: 12px 14px;
  border-left: 3px solid var(--warn);
  background: rgba(232, 163, 58, .08);
  color: var(--ink-mid);
  font-size: .8rem;
  line-height: 1.5;
}

/* Event info carries the same marker for the whole page's data. */
.info-foot {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 26px;
  padding-top: 16px;
  border-top: 1px solid var(--rule-soft);
  color: var(--ink-dim);
  font-size: .8rem;
}
.info-foot .src-btn { margin-left: 0; }

/* ============================================================
   installed-app chrome — install button, offline flag, toast
   ============================================================ */

/* Reads as a stamped label rather than a call-to-action button: it sits in
   the masthead next to the countdown and must not out-shout the hall plates. */
.install-btn {
  background: none;
  border: 1px solid var(--signal);
  color: var(--signal);
  border-radius: var(--radius-sm);
  padding: 3px 10px;
  font-family: var(--font-mono);
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  cursor: pointer;
  transition: background .12s, color .12s;
}
.install-btn:hover { background: var(--signal); color: var(--signal-ink); }

/* The language toggle sits beside the install button in the masthead and
   wears the same stamped-label shape, in the quieter rule/ink pair: it is
   a preference, not an invitation, and the signal colour belongs to the
   one control asking to be pressed. Labelled with the language it switches
   *to*, in that language, which is the one word every reader recognises. */
/* The share button wears the same shape for the same reason: passing the
   guide on is a thing you may want, not a thing the page is asking for. */
.lang-switch,
.header-share {
  background: none;
  border: 1px solid var(--rule);
  color: var(--ink-mid);
  border-radius: var(--radius-sm);
  padding: 3px 10px;
  font-family: var(--font-mono);
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  cursor: pointer;
  transition: border-color .12s, color .12s;
  /* .lang-switch is an <a> (index.html) so that the German guide has an
     address a crawler can follow; the underline that comes with that would
     read as prose in a row of stamped controls. */
  text-decoration: none;
}
.lang-switch:hover,
.header-share:hover { border-color: var(--ink-mid); color: var(--ink); }

.header-share { white-space: nowrap; }

/* Share measured 60 by 22 and reaches 64 by 28. Commit 6181056 fixed it
   against the language control, so its visible box must not move. Footer links
   measured 15px tall and reach about 19px, not 24: the leading between wrapped
   lines leaves only 2px above and below. Their sides stop inside the literal
   middle dot and its surrounding spaces, so one link cannot take another's tap. */
.header-share,
.subtle-link { position: relative; }

.header-share::after { content: ""; position: absolute; inset: -3px -2px; }
.subtle-link::after { content: ""; position: absolute; inset: -2px -2px; }

/* The footer copy runs as prose, so the backstop switcher there reads as
   one more link in the Imprint · Privacy row rather than a second button. */
.lang-switch-link {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  cursor: pointer;
  color: var(--ink-dim);
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-underline-offset: 3px;
}
.lang-switch-link:hover { color: var(--ink); }

/* Only meaningful while the network is gone, so it is absent — not merely
   dimmed — the rest of the time. */
.offline-flag {
  display: none;
  font-family: var(--font-mono);
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--warn);
}
html[data-offline="true"] .offline-flag { display: block; }

.power-flag {
  display: none;
  align-items: baseline;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: 4px 10px;
  padding: 4px 8px;
  border-left: 3px solid var(--warn);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--warn) 10%, transparent);
  font-family: var(--font-mono);
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--warn);
}
html[data-power-saver="true"] .power-flag { display: flex; }
.power-flag button {
  border: 0;
  padding: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.power-flag button:hover { color: var(--signal); }

.toast {
  position: fixed;
  left: 24px;
  right: 24px;
  bottom: calc(24px + env(safe-area-inset-bottom));
  z-index: 100;
  margin: 0 auto;
  max-width: 460px;
  display: flex;
  align-items: center;
  gap: 14px;
  background: var(--ground-2);
  border: 1px solid var(--rule);
  border-left: 3px solid var(--signal);
  border-radius: var(--radius);
  box-shadow: var(--plate-shadow);
  padding: 12px 14px;
  font-size: .84rem;
  color: var(--ink);
}
.toast[hidden] { display: none; }
.toast-text { flex: 1; }
.toast-action {
  flex: 0 0 auto;
  background: var(--signal);
  border: none;
  color: var(--signal-ink);
  border-radius: var(--radius-sm);
  padding: 5px 12px;
  font-family: var(--font-mono);
  font-size: .7rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  cursor: pointer;
}
.toast-dismiss {
  flex: 0 0 auto;
  background: none;
  border: none;
  color: var(--ink-dim);
  font-size: 1.2rem;
  line-height: 1;
  padding: 0 2px;
  cursor: pointer;
}
.toast-dismiss:hover { color: var(--ink); }

/* Standalone has no browser chrome to sit under, so the app has to keep
   itself clear of the notch and the home indicator on its own. */
html[data-standalone="true"] body { padding-top: env(safe-area-inset-top); }
html[data-standalone="true"] .site-footer { padding-bottom: calc(48px + env(safe-area-inset-bottom)); }
.wrap { padding-left: max(24px, env(safe-area-inset-left)); padding-right: max(24px, env(safe-area-inset-right)); }

/* ============================================================
   legal pages — imprint, privacy
   Prose, not app chrome: one narrow column on the same ground, so
   these read as part of the guide without pulling in the masthead.
   ============================================================ */
/* body is a flex column, so main has to grow or the footer floats up the
   page on a short document */
.legal-page main { flex: 1; padding-top: 40px; padding-bottom: 64px; }
.legal-bar { border-bottom: 1px solid var(--rule); padding: 16px 0; }
.legal-bar .wrap { display: flex; align-items: baseline; gap: 14px; }
.legal-bar [role="group"] { margin-left: auto; }
.legal-bar button.subtle-link {
  border: 0;
  padding: 0;
  background: none;
  font: inherit;
  cursor: pointer;
}
.legal-back {
  color: var(--ink-dim);
  text-decoration: none;
  font-family: var(--font-mono);
  font-size: .74rem;
}
.legal-back:hover { color: var(--ink); }
.legal-doc { max-width: 68ch; }
.legal-doc h1 {
  font-family: var(--font-poster);
  font-size: clamp(2rem, 6vw, 2.9rem);
  line-height: 1.05;
  letter-spacing: .01em;
  text-transform: uppercase;
  margin-bottom: 8px;
}
.legal-doc h2 {
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 700;
  margin: 34px 0 8px;
  padding-top: 14px;
  border-top: 1px solid var(--rule-soft);
}
.legal-doc h3 {
  font-size: .9rem;
  font-weight: 700;
  color: var(--ink);
  margin: 18px 0 4px;
}
.legal-doc p, .legal-doc li { color: var(--ink-mid); font-size: .9rem; }
.legal-doc p { margin-bottom: 10px; }
.legal-doc ul { margin: 0 0 12px 18px; display: flex; flex-direction: column; gap: 5px; }
.legal-doc a { color: var(--ink); text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; }
.legal-doc a:hover { text-decoration-color: var(--signal); }
.legal-doc address { font-style: normal; color: var(--ink); line-height: 1.7; }
.legal-doc code { font-family: var(--font-mono); font-size: .8rem; color: var(--ink); }
/* the one thing on these pages worth reading first */
.legal-lede { color: var(--ink) !important; font-size: 1rem !important; border-left: 2px solid var(--signal); padding-left: 14px; }
.legal-updated { font-family: var(--font-mono); font-size: .7rem; color: var(--ink-dim); margin-bottom: 26px; }

@media (max-width: 620px) {
  .legal-page main { padding-top: 26px; }
  .legal-doc h2 { margin-top: 26px; }
}

/* ============================================================
   responsive
   ============================================================ */

@media (max-width: 860px) {
  .footer-inner { grid-template-columns: 1fr 1fr; }
  .priority-item { grid-template-columns: 34px 1fr auto; row-gap: 4px; }
  .priority-loc, .priority-advice { grid-column: 2 / -1; }
  .priority-item > .queue-summary { grid-column: 2 / -1; }
  .route-item { grid-template-columns: 1fr auto auto; row-gap: 4px; }
  .route-booth { grid-column: 1 / -1; grid-row: 2; }
  .route-crowd { grid-column: 2; grid-row: 1; }
  .route-deadline { grid-column: 1 / -1; grid-row: 4; }
  /* Six columns do not fit a tablet, so the day row folds the same way the
     hall row above it does: name, queue and the controls on the first line,
     the address under them, the day chips under that. */
  .it-item { grid-template-columns: minmax(120px, 1fr) auto auto 20px; row-gap: 7px; }
  .it-loc { grid-column: 1 / -1; grid-row: 2; }
  .it-crowd { grid-column: 2; grid-row: 1; }
  .it-item .row-move { grid-column: 3; grid-row: 1; }
  .it-item .it-days { grid-column: 1 / -1; grid-row: 3; justify-content: flex-start; }
  .it-warn { grid-column: 1 / -1; grid-row: 4; }
  .route-item > .queue-summary { grid-column: 1 / -1; }
  .wristband-item { grid-template-columns: 1fr auto; }
  .wristband-loc,
  .wristband-games { grid-column: 1 / -1; justify-self: start; }
}

@media (max-width: 620px) {
  .wrap { padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(16px, env(safe-area-inset-right)); }
  .tabs { padding: 0 16px; }
  .toast { left: 16px; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom)); }
  /* the full masthead is ~290px on a phone — far too much to pin above a
     long scrolling list, so it scrolls away here instead */
  .site-header { position: static; }
  /* …and with nothing pinned on top, the measured header offset above would
     just strand a jumped-to heading a masthead's height down the screen */
  #plan-section,
  #plan-board .it-group,
  #view-planner .section-title,
  #trade,
  .bm, .mv, .day-chip, .src-btn { scroll-margin-top: 16px; }

  /* The phone masthead. The control row dissolves and each button takes its
     own place in the grid's right column: share beside the wordmark, the
     language toggle directly under it beside the dates. Two columns, two
     rows — neither button gets a row of its own, so nothing is pushed down
     the page to make space for a control.

     The install prompt is the exception. It is the only one of the three
     wide enough ("App installieren") to squeeze the wordmark out of its own
     row, so it keeps a full-width line under the dates, where its width
     costs nothing. */
  .header-inner {
    align-items: center;
    column-gap: 8px;
    row-gap: 10px;
    padding-bottom: 14px;
  }
  .brand { grid-row: 1; }
  .header-actions { display: contents; }
  /* End of the wordmark's row, start of the dates' — the two buttons close
     up into a pair across the row gap instead of drifting apart. */
  .header-share { grid-column: 2; grid-row: 1; justify-self: end; align-self: end; }
  .lang-switch { grid-column: 2; grid-row: 2; justify-self: end; align-self: start; }
  /* Left edge with everything else in the stack — right alignment was for
     the wide masthead, and there is nothing here to align against. */
  .header-meta { grid-column: 1; grid-row: 2; text-align: left; }
  .power-flag { justify-content: flex-start; }
  .install-btn { grid-column: 1 / -1; grid-row: 3; justify-self: start; }
  /* ...and that row is the one thing in the masthead that arrives late, so
     on the visits where it is coming it is held open from the first paint.
     index.html stamps the answer before anything paints; see the comment on
     it for why js/pwa.js cannot be the one to say so.

     Reserved rather than shown: the button keeps its hidden attribute until
     pwa.js takes it off, so this rule stops matching in the same frame the
     real button appears, and an iOS visit where pwa.js never loads is left
     with an empty strip rather than a control that does nothing. */
  html[data-install-offer] .install-btn[hidden] {
    display: block;
    visibility: hidden;
  }

  .grid { grid-template-columns: 1fr; }
  .footer-inner { grid-template-columns: 1fr; gap: 26px; }
  .day-row { grid-template-columns: 76px 1fr; row-gap: 8px; }
  .day-access { grid-column: 2; }
  .day-detail { grid-column: 1 / -1; }
  /* full-width like the detail text above it — half-indented it reads as a
     stray line rather than a fact of the day */
  .day-planned { grid-column: 1 / -1; margin-top: 0; }
  .it-group-head { grid-template-columns: 76px 1fr auto; row-gap: 8px; }
  /* Day, access and the map chip share the top line; the hours take the
     whole of the next one rather than the 76px column they would flow into. */
  .it-group-head .day-detail { grid-column: 1 / -1; }
  /* Same fold as the tablet block above, one notch narrower in column one. */
  .it-item { grid-template-columns: minmax(96px, 1fr) auto auto 20px; row-gap: 9px; }
  /* "Open now · closes 20:00 · 3h 40m left" will not share a line with the
     weekday and the access badge at this width, and it is the half of the
     strip that changes — so it takes the second row whole. */
  .today-strip { grid-template-columns: auto 1fr; row-gap: 10px; }
  .today-status { grid-column: 1 / -1; text-align: left; }
  .today-actions { flex-direction: column; align-items: flex-start; gap: 12px; }
  .toolbar-row .row-label,
  .updates-toolbar .row-label { flex-basis: 100%; }
  .queue-plan-live { grid-column: 1 / -1; }
  /* let the summary text take a full line instead of squeezing into a column */
  .toolbar-summary { flex-wrap: wrap; row-gap: 4px; }
  .toolbar-summary .row-label { flex: 0 0 auto; }
  .filter-summary { flex: 1 1 100%; order: 3; }
  .directory-summary { flex-wrap: wrap; row-gap: 4px; }
  .directory-lede { flex: 1 1 100%; order: 3; }
  .field-sort { border-left: none; border-top: 1px solid var(--rule-soft); }
  .toolbar-search { flex-direction: column; align-items: stretch; }
  .result-count { margin-left: 0; flex-basis: 100%; }
  .area-list li { grid-template-columns: 1fr; row-gap: 2px; }
  .sheet { width: calc(100vw - 24px); max-height: calc(100vh - 24px); }
  .sheet-head, .sheet-body { padding: 16px; }
  .queue-prompt { align-items: stretch; flex-direction: column; }
  .queue-prompt-copy { flex-basis: auto; }
  .queue-prompt-actions { display: grid; grid-template-columns: repeat(3, 1fr); }
  .queue-prompt .queue-action { padding-inline: 6px; }
  .share-qr { grid-template-columns: 1fr; }
  .share-qr-image { max-width: 280px; margin: 0 auto; }
  .share-qr p { text-align: center; }
}

/* Under ~370px the venue line and the language toggle cannot share a row —
   "Koelnmesse, Cologne · Aug 26–30, 2026" alone is 250px — and a date
   broken after the comma to seat a button is a bad trade. The toggle takes
   a line of its own under the share button instead, and the dates get the
   full width back. The right column narrows to the share button with it,
   which is also what keeps the wordmark on one line down here. */
@media (max-width: 369px) {
  .lang-switch { grid-column: 1 / -1; grid-row: 2; align-self: auto; }
  .header-meta { grid-column: 1 / -1; grid-row: 3; }
  .install-btn { grid-row: 4; }
}

/* Under ~340px the wordmark and the share button no longer fit on one line
   together, and "gamescom 2026" breaking across two is worse than a slightly
   smaller masthead. Everything gives a few pixels rather than one thing
   giving all of them. */
@media (max-width: 340px) {
  .header-inner { column-gap: 8px; }
  .brand { gap: 10px; }
  .brand-mark { width: 40px; height: 40px; font-size: 1.1rem; }
  .brand-title { font-size: 1.5rem; }
  .header-share { padding: 3px 8px; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

/* ---- scroll anchoring ----

   The browser keeps a page still while things load above the viewport by
   picking a node that is in view and holding it there. Every list below is
   replaced in one innerHTML write, which destroys whichever node it had
   picked, and it then compensates for a shift that never happened: the page
   crept a row up or down on every save, every reorder and every filter.
   Opting the containers out leaves the anchor to be chosen from the stable
   page around them, which is what a rebuild in place wants — the visitor
   pressed a button that was on screen, so the view belongs exactly where they
   left it. */
#exhibitor-grid,
#directory-list,
#trade-list,
#plan-board,
#priority-list,
#wristband-list,
#day-guide { overflow-anchor: none; }

/* ---- boot reservation ----

   The counterpart to the overflow-anchor rules above: those keep the page
   still while a list is rebuilt, this keeps it still while the first one
   arrives.

   index.html stamps data-booting before the first paint and js/app.js takes
   it off once the landing view has rendered — see the comment on the stamp
   for why it cannot be set from a script at the end of the body. Between
   those two moments the grid is empty and everything after it in the
   document — the trade section, the full directory, the footer — sits inside
   the first screen, waiting to be shoved off it the moment the cards land. A
   footer leaving the viewport is scored against the whole viewport, so that
   single frame carried most of the guide's layout shift.

   A screen of grid is exactly enough to put all of it below the fold, where
   the cards can arrive for free. Overshooting is free too: this is replaced
   by real content in the same frame that removes the stamp, and the grid is
   thousands of pixels tall by then. That first render puts in a few screens
   of cards and leaves the tail for the next task (see renderExhibitors),
   which changes nothing here — a few screens is already several times the
   one this gives back, and the tail lands below the fold.

   :empty is the second half of it, not decoration, and it carries three
   cases the stamp alone would not. A boot that fails writes its message into
   the grid (see main() in js/app.js), and the reservation has to come off in
   the frame that fills it rather than wait for a stamp that failed boot never
   removes. A filter matching nothing leaves an empty grid too, and that is a
   page which should be short. And it is what keeps this out of sw.js's
   version bump: index.html is network-first while this file and js/app.js
   ride stale-while-revalidate, so a fresh shell can be paired with an
   app.js that never heard of the stamp — one that renders cards anyway, and
   drops the reservation by filling the grid. Every other pairing is a rule
   or a stamp that is simply absent, which is the behaviour these replace. */
html[data-booting] #exhibitor-grid:empty { min-height: 100vh; }

/* ---- rendering cost ----

   Laying these lists out all at once is most of what froze the first seconds
   on a phone: 142 cards, up to 200 directory rows behind them. renderKeyed()
   keeps a re-render from rebuilding the rows it already has, but the first
   time each list is built there is nothing to reuse, and every row after it
   is scrolled to has to be laid out at some point regardless.
   content-visibility lets the browser skip layout and paint for everything
   below the fold until it is scrolled toward, so the cost of a list tracks
   the viewport rather than the dataset.

   The intrinsic sizes are rough per-item heights — they only steer the
   scrollbar's first guess and are replaced by real geometry once an item has
   been on screen — but rough has to mean the right order of magnitude. A
   directory row is 80px stacked on a phone and 38px as a table row above
   640px, and the single 60px estimate that covered both was wrong by 20px in
   either direction, which is 4000px of scrollbar across 200 rows. Browsers
   without the property just render everything, which is exactly the behaviour
   this replaces. The changelog timeline stays out: its date dot is drawn 31px
   outside the entry's box, and the paint containment this property brings
   would clip it off.

   The planner's own lists stay out too, which is a change of mind rather than
   an oversight. They are short — a plan holds what one visitor saved, the
   queue table is 23 rows — so there was never much layout to skip, and they
   are rebuilt on every save, every reorder and every day assignment. Each
   rebuild is a fresh set of elements, so every row off screen collapses back
   to its estimate before being measured again, and the document snapped by as
   much as 1655px in the frame after a plan render — under a visitor who was
   reading the queue table below it. On a 142-stop plan, dropping the property
   costs about 24ms per render on a desktop and saves about 110ms on a phone. */
.card { content-visibility: auto; contain-intrinsic-size: auto 540px; }
.dir-row { content-visibility: auto; contain-intrinsic-size: auto 38px; }
@media (max-width: 640px) {
  .dir-row { contain-intrinsic-size: auto 80px; }
}
