/* Malmö Street Quest — Malmöfestivalen visual language.
 *
 * Constraints that drove every choice here (carried over unchanged from v1 —
 * the look changed, the physics did not):
 *  - Read in direct August sunlight  -> black ink on white paper (21:1)
 *  - Held one-handed with a beer     -> primary action always in the bottom bar
 *  - iOS home-screen standalone      -> safe-area insets, dvh, no overscroll
 *  - No network                      -> system fonts only, zero external assets
 *
 * The look is taken from malmofestivalen.se: purple brand bands, flat accent
 * colours, everything outlined in black, and a hard un-blurred drop shadow on
 * every object. Three rules make it survive the constraints above:
 *
 *  1. PURPLE IS A BAND COLOUR, NEVER A READING SURFACE. White on #6D44D4 is
 *     6.13:1 and black on it is 3.43:1 — neither clears the sunlight bar. So
 *     display type may sit on purple; body copy never does. Every screen that
 *     uses the purple ground puts its readable content inside a white card.
 *  2. EVERY ACCENT CLEARS 7:1 AGAINST BLACK INK — coral 7.02, green 10.6,
 *     pink 12.05, yellow 14.9, chip-grey 18.4. That is why every object here
 *     is outlined and lettered in black rather than white. It is also why the
 *     accents are safe to use as large fills.
 *  3. THE SHADOW IS AN OBJECT, NOT AN EFFECT: zero blur, offset roughly twice
 *     the border width. Pressing translates the element INTO its own shadow,
 *     which is what makes the whole thing feel physical rather than flat.
 *
 * The reference uses a proprietary display face ("Malmofest"). We cannot ship
 * it — it is someone else's licensed brand asset and it would break the
 * zero-external-assets rule. The weight/case/tracking below reproduce the
 * posture with the system stack instead.
 */

:root {
  /* ── festival palette ─────────────────────────────────── */
  --purple:     #6D44D4;   /* brand band. NOT a reading surface. */
  --purple-dk:  #4B2BA0;
  --ink:        #000000;   /* outlines, shadows, and all body copy */
  --ink-soft:   #4F4F4F;   /* 8.9:1 on white — still sunlight-safe */
  --paper:      #FFFFFF;   /* the reading surface */
  --chip:       #F0F0F0;   /* input + inert chip fill, 18.4:1 with ink */
  --deep:       #292929;
  --yellow:     #FFD631;   /* 14.90:1 with ink */
  --coral:      #FF6047;   /*  7.02:1 with ink */
  --pink:       #FFADC6;   /* 12.05:1 with ink */
  --green:      #00D45A;   /* 10.57:1 with ink */

  /* --ground is the current screen's ground, overridden per screen so the
     bottom bar's fade always resolves against whatever it is sitting on. */
  --ground: var(--paper);

  --tap: 52px;                 /* > 44px iOS minimum */
  --pad: 22px;
  --radius: 8px;
  --radius-pill: 999px;

  /* The signature. Zero blur is the whole point — a blurred shadow reads as
     depth, an offset solid reads as a second object behind the first. */
  --line-w: 3px;
  --shadow: 6px 6px 0 var(--ink);
  --shadow-sm: 4px 4px 0 var(--ink);
  --shadow-press: 2px 2px 0 var(--ink);

  --safe-t: env(safe-area-inset-top, 0px);
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-l: env(safe-area-inset-left, 0px);
  --safe-r: env(safe-area-inset-right, 0px);
}

*, *::before, *::after { box-sizing: border-box; }

/* The `hidden` attribute is applied by the UA stylesheet, which any author
   `display` rule outranks. `.btn { display: block }` therefore beat
   `$('btn-checkin').hidden = true`, and a station you had already stamped
   showed the ✓ Stamped panel AND a live Check in button underneath it. This is
   one line because the bug is a whole class — every element app.js toggles
   with `.hidden` is exposed the moment someone gives its class a display rule,
   and nobody making that edit would think to check. */
[hidden] { display: none !important; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  overscroll-behavior: none;          /* kill pull-to-refresh bounce */
}

/* The canvas — what paints wherever #app does not reach. #app is a fixed box
   exactly 100dvh tall, and every mobile engine has moments where the visible
   area is taller than that: the toolbar mid-retract, the band behind the home
   indicator, a rounded-up device pixel. Whatever shows through there is the
   canvas, which propagates from <body> because <html> has no background of its
   own. White under a purple bottom bar reads as a hairline seam, so the canvas
   follows the active screen's ground rather than being white always.
   `:has()` support is a superset of 100dvh support (Safari 15.4 / Chrome 105
   vs 108), so any engine that lays this app out correctly honours the rule;
   older ones land on today's behaviour rather than on nothing. */
body {
  background: var(--paper);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  font-size: 17px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-text-size-adjust: 100%;
}
body:has(.screen--brand.is-active) { background: var(--purple); }

#app {
  position: fixed;                    /* never 100vh — iOS lies about it */
  inset: 0;
  height: 100dvh;
  overflow: hidden;
}

/* ── screens ───────────────────────────────────────────── */

.screen {
  position: absolute;
  inset: 0;
  display: none;
  flex-direction: column;
  background: var(--ground);
}
.screen.is-active { display: flex; }

/* Purple band screens. Redefining --ground here is what keeps .bar's fade and
   the progress ring's knockout correct without either of them knowing which
   screen they are on. */
.screen--brand {
  --ground: var(--purple);
  color: var(--paper);
}

.body {
  flex: 1;
  overflow-y: auto;
  /* Explicit, and load-bearing. A box with overflow-y:auto and overflow-x
     unset computes overflow-x:auto too, which does two bad things: any child
     that blows out its width scrolls sideways instead of clipping, and iOS
     Safari lets you rubber-band pan a scroll container even when there is
     nothing to scroll. Both read to the user as "the page is wider than the
     phone". */
  overflow-x: hidden;
  -webkit-overflow-scrolling: touch;
  padding: calc(var(--safe-t) + var(--pad)) calc(var(--safe-r) + var(--pad))
           var(--pad) calc(var(--safe-l) + var(--pad));
  position: relative;                 /* stacking context above .deco */
  z-index: 1;
}
/* `safe center` rather than `center`, and the two declarations are deliberate.
   A centred flex column whose content is TALLER than the box overflows equally
   in both directions — and the overflow past the start edge sits before the
   scroll origin, so it cannot be scrolled to. On a 320px phone that silently
   ate the top half of the STREET QUEST lockup. `safe` falls back to flex-start
   the moment content stops fitting; the plain `center` above it is the
   fallback for engines that do not know the keyword, which land on today's
   behaviour rather than on nothing. */
.body--center {
  display: flex;
  flex-direction: column;
  justify-content: center;
  justify-content: safe center;
}

/* The scroll cue: a fade at the bottom edge whenever content continues below
   it. app.js toggles .body--more from the container's scroll position.
 *
 * `position: sticky` rather than absolute, because an absolutely-positioned
 * child of a scroll container scrolls away with the content it is supposed to
 * be sitting in front of. The negative margin cancels the height again so the
 * cue overlays the last rows instead of reserving a strip under them.
 *
 * --ground is the current screen's background, so this fades to purple on a
 * brand screen and to paper elsewhere without knowing which one it is on —
 * the same indirection .bar's border already relies on.
 *
 * Excluded on .body--center: those screens centre their children as flex
 * items, and a pseudo-element joining that layout would shift the thing they
 * exist to centre. They are also not the screens ISC-490 is about. */
.body:not(.body--center)::after {
  content: '';
  display: block;
  position: sticky;
  /* NOT `bottom: 0`. Sticky resolves its offset against the scrollport's
     CONTENT edge, so `0` parks the cue a full `--pad` above the container's
     real bottom — 22px of clear air between the fade and the bar it is
     supposed to meet. It read as a band hovering in the lower third of the
     screen rather than an edge, which is exactly how it was reported.
     Pulling it down by the container's own bottom padding puts it on the
     boundary, and lets it wash the padding strip that content scrolls
     through as well. */
  bottom: calc(-1 * var(--pad));
  /* The height has to cover the padding it now extends into, or the gradient
     would simply be moved down and the same strip left unfaded. */
  height: calc(34px + var(--pad));
  margin-top: calc(-34px - var(--pad));
  background: linear-gradient(to top, var(--ground), transparent);
  /* Never eats a tap meant for the row underneath it. */
  pointer-events: none;
  opacity: 0;
  /* A transition, not an animation: it runs on a state change and then stops,
     so nothing is still running while the app sits idle. */
  transition: opacity 140ms ease;
}
/* The `:not()` is repeated here and it is load-bearing, not decoration.
   `:not(.body--center)` contributes its argument's specificity, so the rule
   above scores two classes and this one — written as a bare `.body--more::after`
   — scored one and LOST to it. The cue computed opacity 0 on every screen at
   every scroll position: shipped, tested, and never once visible.
   Matching the selector shape makes the two equal and lets source order
   decide, which is what was intended all along. Reported from the app, and
   the reason the tests missed it is that they asserted the CLASS was applied,
   in a harness with no cascade — D-102. */
.body--more:not(.body--center)::after { opacity: 1; }

/* ── decoration ────────────────────────────────────────── */

/* Flat outlined shapes, the festival's other signature. Every one of these is
   a hazard: absolutely-positioned children are exactly the class of thing that
   caused the ISC-25 horizontal blowout. Three rules contain them and none is
   cosmetic — the clipping layer stops any shape widening the screen, the
   pointer-events:none stops them eating taps meant for the form beneath, and
   they sit at z-index 0 under .body's z-index 1 so they can never cover
   content. Shapes are sized in vw/vmin so they scale rather than overflow. */
.deco {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 0;
}
.deco span {
  position: absolute;
  display: block;
  border: var(--line-w) solid var(--ink);
}
.deco__blob {
  width: 46vmin;
  height: 46vmin;
  top: -14vmin;
  right: -16vmin;
  background: var(--green);
  border-radius: 50% 50% 50% 12%;
  transform: rotate(-12deg);
}
.deco__zig {
  width: 34vmin;
  height: 34vmin;
  left: -13vmin;
  bottom: 16vmin;
  background: var(--coral);
  border-radius: 50% 12% 50% 50%;
  transform: rotate(8deg);
}
.deco__star {
  width: 18vmin;
  height: 18vmin;
  right: 6vmin;
  bottom: 6vmin;
  background: var(--yellow);
  border-radius: 12% 50% 12% 50%;
  transform: rotate(18deg);
}

/* ── bars ──────────────────────────────────────────────── */

/* Bottom action bar — every primary control lives in thumb reach. */
.bar {
  flex: none;
  position: relative;
  z-index: 2;
  padding: 14px calc(var(--safe-r) + var(--pad))
           calc(var(--safe-b) + 14px) calc(var(--safe-l) + var(--pad));
  background: var(--ground);
  border-top: var(--line-w) solid var(--ink);
}
.bar--stack { display: grid; gap: 12px; }

/* Inside a bar the grid gap owns the spacing, so the field's own bottom margin
   would double it — and every pixel here is one the keyboard does not leave. */
.bar .field,
.bar .error { margin: 0; }
.bar .field__label { margin-bottom: 6px; }

.top {
  flex: none;
  position: relative;
  z-index: 2;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  /* WRAPS, and the text takes a whole row of its own — see .top__text.
     With help, Map and the ring in the row, the three controls plus their gaps
     and the safe-area padding leave about 112px on a 390px phone, and "STREET
     QUEST" at 26px needs closer to 200. Every phone width ellipsised the
     wordmark to "STRE…" and the team name to "THE H…", which is the header
     failing at the one job it has. */
  flex-wrap: wrap;
  gap: 12px;
  padding: calc(var(--safe-t) + 16px) calc(var(--safe-r) + var(--pad))
           14px calc(var(--safe-l) + var(--pad));
  background: var(--purple);
  color: var(--paper);
  border-bottom: var(--line-w) solid var(--ink);
}
/* Its own row, at every width rather than under a breakpoint. Vertical space is
   the cheap axis here — the body scrolls and this does not — so the header
   grows about 35px and the wordmark, the team name and all three controls stay
   whole. A breakpoint would only be a guess about which phones need it, and the
   arithmetic above says all of them do. */
.top__text { flex: 1 1 100%; min-width: 0; }
.top__title {
  margin: 0;
  font-size: 26px;
  font-weight: 900;
  line-height: 1;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  /* The ISC-25 remedy, applied before it is needed rather than after. The
     eyebrow above has carried nowrap + ellipsis since the grid blowout; the
     title never did, because until a fourth control arrived there was always
     room. A 26px word in a 40px box overflows its parent and scrolls the whole
     screen sideways — the exact failure that cost this project twenty
     sessions. min-width:0 above is what lets this actually clip. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The chat control, beside Map. A circle rather than a labelled pill because a
   "Help" word would eat the team name at 320px, and the aria-label carries
   what the glyph cannot. Square by construction: a 44px-tall control 20px wide
   is not a 44px target, and this is the one somebody reaches for when lost.
   `position: relative` so the unread dot can hang off its corner. */
.btn-help {
  position: relative;
  flex: none;
  display: grid;
  place-items: center;
  width: 44px;
  min-width: 44px;
  height: 44px;
  padding: 0;
  font-size: 20px;
  font-weight: 900;
  line-height: 1;
  text-decoration: none;
  background: var(--paper);
}

/* Coral, which is the one accent not otherwise spoken for on this screen, and
   carrying the COUNT rather than being a bare dot — a number is legible to a
   screen reader and to somebody deciding whether it is worth a tap. Black on
   coral is 7.02:1. */
.btn-help__dot {
  position: absolute;
  top: -6px;
  right: -6px;
  min-width: 22px;
  height: 22px;
  padding: 0 5px;
  display: grid;
  place-items: center;
  font-size: 12px;
  font-weight: 900;
  color: var(--ink);
  background: var(--coral);
  border: 2px solid var(--ink);
  border-radius: var(--radius-pill);
}

/* ── chat ──────────────────────────────────────────────── */

.chat__intro {
  margin: 0 0 18px;
  font-size: 15px;
  line-height: 1.5;
  color: var(--ink-soft);
}

.chat {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 14px;
  /* Same pairing ISC-25 and ISC-113 required. A long unbroken word in a
     message is exactly the min-content blowout that shipped a sideways
     scrolling quest list once, and this list renders strings somebody else
     typed. */
  grid-template-columns: minmax(0, 1fr);
}
.chat > li { min-width: 0; }

.chat__msg {
  display: grid;
  gap: 4px;
  max-width: 88%;
  padding: 12px 14px;
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
  background: var(--chip);
}
/* The walker's own messages sit right and wear the brand yellow; the team's
   sit left on white. Position AND colour, not colour alone, so the distinction
   survives forced-colors and a monochrome sunlit screen. */
.chat__msg--walker { justify-self: end; background: var(--yellow); }
.chat__msg--org { justify-self: start; background: var(--paper); }

.chat__who {
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
}
.chat__body {
  margin: 0;
  font-size: 16px;
  line-height: 1.45;
  color: var(--ink);
  /* A pasted URL or a long unbroken string must wrap rather than widen the
     column. `anywhere` over `break-word` because it also affects min-content,
     which is what the grid track is sized from. */
  overflow-wrap: anywhere;
}
/* The timestamp is de-emphasised by SIZE, and its colour depends on what it is
   sitting on. `--ink-soft` is 8.9:1 on white and only 5.82:1 on the brand
   yellow, which is under this project's 7:1 sunlight bar — so the walker's own
   bubbles get full ink. Same move as D-9: split by surface rather than argue
   that a timestamp is not really body text. */
.chat__when {
  font-size: 12px;
  font-weight: 700;
  color: var(--ink-soft);
  justify-self: end;
}
.chat__msg--walker .chat__when { color: var(--ink); }

/* The narrowest phone still in the field. The controls row is the tight one
   now, not the text row: ? + Map + ring is 198px of control against 232px of
   usable width at 320. */
@media (max-width: 360px) {
  .top { gap: 10px; }
}
.top__eyebrow {
  display: inline-block;
  max-width: 100%;
  margin: 0 0 6px;
  padding: 3px 10px;
  font-size: 12px;
  font-weight: 800;
  color: var(--ink);
  background: var(--yellow);
  border: 2px solid var(--ink);
  border-radius: var(--radius-pill);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── type ──────────────────────────────────────────────── */

/* align-self matters as much as display here. Both .eyebrow and .tag are direct
   children of .body--center, which is a flex column — so inline-block alone
   still lets them stretch edge to edge, and a full-bleed "tag" reads as a
   banner instead of something pinned on by hand. */
.eyebrow {
  display: inline-block;
  align-self: flex-start;
  max-width: 100%;
  margin: 0 0 14px;
  padding: 5px 12px;
  font-size: 13px;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink);
  background: var(--yellow);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius-pill);
  box-shadow: var(--shadow-sm);
}

/* The lockup. Uppercase, maximum weight, line-height at 1 so the two words
   stack as one block, and a hard black offset behind it — the same move the
   festival makes with its wordmark over the hero photo. */
.hero {
  margin: 0 0 16px;
  font-size: clamp(44px, 15vw, 66px);
  line-height: 0.9;
  letter-spacing: -0.01em;
  font-weight: 900;
  text-transform: uppercase;
  color: var(--paper);
  text-shadow: 4px 4px 0 var(--ink);
}
.hero--sm {
  font-size: clamp(30px, 9.5vw, 42px);
  line-height: 0.95;
  text-shadow: 3px 3px 0 var(--ink);
}

/* Rotated tag. The festival hangs these off its hero ("7–14 AUGUSTI 2026") and
   they are most of what makes the page feel hand-assembled rather than laid
   out. Rotation is small and the element is inline-block so the tilt cannot
   widen the line box enough to matter. */
.tag {
  display: inline-block;
  align-self: flex-start;
  max-width: 100%;
  margin: 0 0 26px;
  padding: 7px 14px;
  font-size: 14px;
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  background: var(--paper);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
  transform: rotate(-2.5deg);
}

/* White content card. Rule 1 in the header comment made flesh: anything the
   user actually has to READ lives in one of these, never on the purple. */
.card {
  padding: 20px 18px;
  color: var(--ink);
  background: var(--paper);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

.lede { margin: 0 0 18px; font-size: 17px; color: var(--ink); }
.lede:last-child { margin-bottom: 0; }

.promises {
  margin: 0 0 20px;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 7px;
}
.promises li {
  padding: 5px 11px;
  font-size: 13px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink);
  background: var(--chip);
  border: 2px solid var(--ink);
  border-radius: var(--radius-pill);
}

/* ── form ──────────────────────────────────────────────── */

.field { display: block; margin: 0 0 14px; }
.field__label {
  display: block;
  margin-bottom: 8px;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink);
}
/* Only the labels that carry a counter become a row, so the check-in field's
   label keeps the plain block layout it was built with. */
.field__label--counted {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}
/* Tabular figures, so the count does not jiggle the label as the digits
   change width on the way from 9/24 to 10/24. */
.field__count {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  opacity: 0.7;
}

.field input {
  width: 100%;
  min-height: var(--tap);
  padding: 12px 15px;
  font: inherit;
  font-size: 18px;
  font-weight: 600;
  color: var(--ink);
  background: var(--chip);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  appearance: none;
}
/* :focus (not :focus-visible) for the fill change — touch users get the
   affordance too. The visible RING is defined once, globally, below. */
.field input:focus {
  background: var(--paper);
}
#code-input {
  font-size: 26px;
  font-weight: 900;
  letter-spacing: 0.28em;
  text-indent: 0.28em;              /* re-centre: tracking pads only the right */
  text-transform: uppercase;
  text-align: center;
}

.error {
  margin: 0 0 14px;
  padding: 10px 14px;
  color: var(--ink);
  background: var(--coral);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  font-size: 15px;
  font-weight: 700;
}

/* ── buttons ───────────────────────────────────────────── */

.btn {
  display: block;
  width: 100%;
  min-height: var(--tap);
  padding: 15px 20px;
  font: inherit;
  font-size: 17px;
  font-weight: 800;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink);
  background: var(--paper);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
}
/* Press = the object moves into its own shadow. Transition is on transform and
   box-shadow only, so it stays on the compositor. */
.btn:active {
  transform: translate(4px, 4px);
  box-shadow: var(--shadow-press);
}
.btn--primary { background: var(--yellow); }
.btn--ghost { background: var(--chip); }
.btn--danger {
  margin-top: 26px;
  background: var(--paper);
  box-shadow: var(--shadow-sm);
}
.btn[disabled] { opacity: 0.45; }

.btn-back {
  min-height: 44px;
  padding: 6px 12px;
  font: inherit;
  font-size: 14px;
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  background: var(--paper);
  border: 2px solid var(--ink);
  border-radius: var(--radius-pill);
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
}
.btn-back:active { transform: translate(2px, 2px); }
.btn-back span { font-size: 18px; line-height: 1; vertical-align: -1px; }

/* ── progress ring ─────────────────────────────────────── */

.ring {
  position: relative;
  flex: none;
  width: 62px;
  height: 62px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: var(--line-w) solid var(--ink);
  /* background-color is set separately from the gradient on purpose. The
     shorthand would reset it to transparent, which leaves the count sitting on
     whatever band the header happens to be — black on purple, 3.43:1. Keeping
     an opaque white underneath means the readable surface is real rather than
     borrowed from the ::after disc, and a browser that cannot do conic-gradient
     degrades to a plain white dial instead of a purple hole. */
  background-color: var(--paper);
  background-image:
    conic-gradient(var(--yellow) calc(var(--pct, 0) * 1%), var(--chip) 0);
}
.ring::after {
  content: "";
  position: absolute;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 2px solid var(--ink);
  background: var(--paper);
}
.ring__count {
  position: relative;
  z-index: 1;
  font-size: 14px;
  font-weight: 900;
  color: var(--ink);
  letter-spacing: -0.02em;
}

/* ── station list ──────────────────────────────────────── */

/* grid-template-columns and the li min-width are both required, and neither is
   cosmetic. Grid items default to min-width:auto, so a track sized `auto`
   inflates to its items' MIN-CONTENT width — and .station__sub is
   white-space:nowrap, whose min-content is the entire unbroken hint string.
   That is what made these buttons 430px wide inside a 375px iPhone. Capping
   the track at minmax(0, 1fr) and zeroing the item minimum lets the row shrink
   to the viewport, which is also what finally allows the ellipsis on
   .station__sub to do its job. */
.stations {
  margin: 0 0 8px;
  padding: 0;
  list-style: none;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 14px;
}
.stations > li { min-width: 0; }

/* Divider between the open stops and the stamped ones. Sized down and spaced
   so it reads as a seam in the list rather than as another row of it. */
.stations__head { margin: 6px 0 0; }
.stations__head-text {
  margin: 0;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.72;
}

/* The coloured spine is a direct lift of the festival's category chips, where
   MUSIK / KULTUR / BARN & FAMILJ each carry their own colour bar on the left.
   Here the colour is decorative rather than semantic — it cycles so an
   eleven-row list reads as a set of objects instead of a table. */
.station {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  min-height: var(--tap);
  padding: 14px 16px;
  text-align: left;
  font: inherit;
  color: var(--ink);
  background: var(--paper);
  border: var(--line-w) solid var(--ink);
  border-left-width: 12px;
  border-left-color: var(--spine, var(--purple));
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
}
.station:active {
  transform: translate(3px, 3px);
  box-shadow: var(--shadow-press);
}
.stations > li:nth-child(5n+1) .station { --spine: var(--coral); }
.stations > li:nth-child(5n+2) .station { --spine: var(--yellow); }
.stations > li:nth-child(5n+3) .station { --spine: var(--purple); }
.stations > li:nth-child(5n+4) .station { --spine: var(--pink); }
.stations > li:nth-child(5n+5) .station { --spine: var(--green); }

.station--done { background: var(--chip); }
.station--done .station__title { text-decoration: line-through; }

.station__no {
  flex: none;
  width: 32px;
  height: 32px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 2px solid var(--ink);
  background: var(--chip);
  color: var(--ink);
  font-size: 14px;
  font-weight: 900;
}
.station--done .station__no { background: var(--green); }

.station__body { min-width: 0; flex: 1; }
.station__title {
  display: block;
  font-weight: 800;
  font-size: 17px;
  text-transform: uppercase;
  letter-spacing: 0.01em;
  line-height: 1.15;
}
.station__sub {
  display: block;
  font-size: 14px;
  color: var(--ink-soft);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── station detail ────────────────────────────────────── */

.station__name {
  margin: 0 0 6px;
  font-size: clamp(30px, 9vw, 40px);
  font-weight: 900;
  line-height: 0.95;
  letter-spacing: -0.01em;
  text-transform: uppercase;
}
.station__sv {
  display: inline-block;
  margin: 0 0 20px;
  padding: 4px 11px;
  font-size: 14px;
  font-weight: 700;
  color: var(--ink);
  background: var(--pink);
  border: 2px solid var(--ink);
  border-radius: var(--radius-pill);
}
.station__task {
  margin: 0 0 24px;
  padding: 16px 18px;
  font-size: 17px;
  font-weight: 600;
  color: var(--ink);
  background: var(--yellow);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
}

.stamped { text-align: center; padding: 16px 0; }
.stamped__mark {
  margin: 0 auto 14px;
  width: 84px;
  height: 84px;
  display: grid;
  place-items: center;
  font-size: 40px;
  font-weight: 900;
  color: var(--ink);
  background: var(--green);
  border: var(--line-w) solid var(--ink);
  border-radius: 50%;
  box-shadow: var(--shadow);
  transform: rotate(-6deg);
}
.stamped__text {
  margin: 0;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  font-size: 14px;
  color: var(--ink);
}

/* ── done ──────────────────────────────────────────────── */

.stamp-grid {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  /* 104px is the width at which the longest station name on the quest
     ("RIBERSBORGS") fits on one line at this size, so names wrap between words
     instead of shattering mid-word. The min() guard is what stops a fixed
     104px track from being wider than its own container on a narrow phone —
     without it this is the auto-fill version of the ISC-25 blowout. */
  grid-template-columns: repeat(auto-fill, minmax(min(104px, 100%), 1fr));
  gap: 10px;
}
.stamp-grid li {
  min-width: 0;
  padding: 10px 6px;
  font-size: 11px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  text-align: center;
  overflow-wrap: anywhere;          /* long place names must wrap, not widen */
  color: var(--ink);
  background: var(--paper);
  border: 2px solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow-press);
}
.stamp-grid li:nth-child(5n+1) { background: var(--coral); }
.stamp-grid li:nth-child(5n+2) { background: var(--yellow); }
.stamp-grid li:nth-child(5n+3) { background: var(--paper); }
.stamp-grid li:nth-child(5n+4) { background: var(--pink); }
.stamp-grid li:nth-child(5n+5) { background: var(--green); }

/* ── support ───────────────────────────────────────────── */

/* Only ever shown on the done screen, after eleven stamps — asking before the
   walk is finished would be asking a stranger. Coral rather than the yellow
   primary so it reads as an aside and never competes with "Start a new quest",
   which is still the action the bottom bar owns. Same external-link rules as
   the maps hand-off: rel=noopener and no target, so iOS leaves the standalone
   app cleanly instead of stranding the walker in a chromeless view. */
.support {
  margin-top: 20px;
  padding-top: 18px;
  border-top: 2px dashed var(--ink);
}
.support__text {
  margin: 0;
  font-size: 15px;
  line-height: 1.45;
  color: var(--ink);
}

/* ── the attempt log ───────────────────────────────────── */

/* What you already tried at this checkpoint. Soft ink at 8.9:1, a step below
   the task text it sits under, because this is a record rather than an
   instruction — it must be legible in sunlight without competing with the
   riddle. Same dashed rule the helpline and the coffee ask use: an aside
   beneath the content, not another piece of it. */
.tries {
  margin-top: 20px;
  padding-top: 16px;
  border-top: 2px dashed var(--ink);
}
.tries__label {
  margin: 0 0 10px;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-soft);
}
.tries__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 6px;
  /* The ISC-25 pairing again. A walker can type eight characters with no space
     in them, and a fixed track would let that push the screen sideways. */
  grid-template-columns: minmax(0, 1fr);
}
.tries__item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  min-width: 0;
  padding: 6px 10px;
  background: var(--chip);
  border-radius: var(--radius);
}
.tries__word {
  min-width: 0;
  font-weight: 800;
  letter-spacing: 0.08em;
  /* Struck through, because the whole meaning of this list is "these are ruled
     out". Colour alone would carry it for most people and for nobody using a
     forced-colour mode. */
  text-decoration: line-through;
  color: var(--ink-soft);
  overflow-wrap: anywhere;
}
.tries__when {
  flex: none;
  font-size: 12px;
  color: var(--ink-soft);
}

/* ── the wall ──────────────────────────────────────────── */

.wall-claim {
  margin-top: 20px;
  padding-top: 18px;
  border-top: 2px dashed var(--ink);
  text-align: left;
}
.wall-claim__title {
  margin: 0 0 8px;
  font-size: 17px;
  font-weight: 900;
  letter-spacing: -0.01em;
}
.wall-claim__text {
  margin: 0 0 14px;
  font-size: 14px;
  line-height: 1.45;
  color: var(--ink-soft);
}

/* A fieldset carries a border and padding from the UA stylesheet that no other
   grouping in this app has; stripped rather than restyled, because the grouping
   here is semantic (it is what makes the two radios one question to a screen
   reader) and not visual. */
.wall-kind {
  margin: 0 0 14px;
  padding: 0;
  border: 0;
}
.wall-kind__opt {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  min-height: 44px;              /* thumb target, not text height */
  padding: 6px 0;
  font-size: 14px;
  line-height: 1.4;
  cursor: pointer;
}
.wall-kind__opt input {
  flex: none;
  width: 20px;
  height: 20px;
  margin-top: 2px;
  accent-color: var(--purple);
}

.wall-state {
  margin: 20px 0 0;
  padding-top: 18px;
  border-top: 2px dashed var(--ink);
  font-size: 14px;
  line-height: 1.45;
  text-align: left;
  color: var(--ink);
}

/* The board itself. Deliberately not numbered and not ordered by anything but
   recency — it is a wall, and a rank column would make it a race the quest
   never measured. */
.wall {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 10px;
  grid-template-columns: minmax(0, 1fr);
}
.wall__note {
  margin: 0 0 16px;
  font-size: 14px;
  line-height: 1.45;
  color: var(--ink-soft);
}
.wall__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  min-width: 0;
  padding: 12px 14px;
  background: var(--paper);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
}
.wall__name {
  min-width: 0;
  font-weight: 900;
  /* A name on this wall was typed by a stranger. 24 characters with no space in
     them is a legal name and would blow the row out without this. */
  overflow-wrap: anywhere;
}
.wall__meta {
  flex: none;
  font-size: 12px;
  color: var(--ink-soft);
}
.wall__empty {
  margin: 0;
  font-size: 14px;
  line-height: 1.45;
  color: var(--ink-soft);
}

/* The button itself is a .btn in the bottom bar. Only two things separate it
   from the rest: it is an <a> rather than a <button>, so it needs the anchor
   resets that .btn never had to carry, and it is coral — the one accent not
   otherwise spoken for, so the ask is distinct from both the ghost and the
   yellow primary without outranking either. Black on coral is 7.02:1. */
.btn--support {
  background: var(--coral);
  text-decoration: none;
  text-align: center;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ── install hint ──────────────────────────────────────── */

.install-hint {
  margin-top: 12px;
  padding: 14px 16px;
  font-size: 15px;
  line-height: 1.45;
  color: var(--ink);
  background: var(--chip);
  border: var(--line-w) dashed var(--ink);
  border-radius: var(--radius);
}
.glyph {
  display: inline-block;
  padding: 1px 7px;
  border-radius: 5px;
  background: var(--paper);
  border: 2px solid var(--ink);
  font-size: 13px;
  font-weight: 800;
}

/* Android only, and only when the browser actually offers a prompt. It sits
   INSIDE the dashed hint rather than in the bar, because it is the last resort
   of an explanation, not a fourth pinned control — D-29 measured what a third
   one costs on a 320px screen and a fourth is not free. */
.btn--install {
  margin-top: 12px;
  width: 100%;
  background: var(--green);
}

/* ── checkpoint media ──────────────────────────────────── */

/* Same object language as everything else: bordered, offset-solid shadow,
   sitting on white. The container is deliberately styleless when empty — a
   station with no media must leave no gap, no rule and no orphaned caption,
   which `:empty` guarantees far more reliably than app.js remembering to. */
.station__media:empty { display: none; }
.station__media {
  display: grid;
  gap: 16px;
  margin: 0 0 18px;
}

.media { margin: 0; }

.media__image {
  display: block;
  width: 100%;
  height: auto;
  /* min-width:0 in a grid track, same pairing ISC-25 and ISC-113 required.
     An intrinsically wide photo is exactly the min-content blowout that shipped
     a horizontally scrolling quest list once. */
  min-width: 0;
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
  background: var(--chip);
  cursor: pointer;
}

/* The "tap to enlarge" line under the thumbnail. Centred under the picture and
   quiet — it is an instruction, not a caption for the photo's content. */
.media__zoom {
  margin: 6px 0 0;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-align: center;
  opacity: 0.72;
}

/* ── the photo overlay ─────────────────────────────────── */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: calc(var(--safe-t) + 16px) calc(var(--safe-r) + 16px)
           calc(var(--safe-b) + 16px) calc(var(--safe-l) + 16px);
  background: rgba(0, 0, 0, 0.92);
  /* Stops a pinch or a drag that runs past the picture from scrolling the
     screen still sitting behind the overlay. */
  overscroll-behavior: contain;
  touch-action: none;
}

.lightbox__image {
  max-width: 100%;
  max-height: 100%;
  /* contain, so a portrait sign is never cropped to fill a landscape phone —
     the whole point of opening it is to read the thing. */
  object-fit: contain;
  /* The pinch scales from the middle, which is where the picture already is. */
  transform-origin: center center;
}

.lightbox__close {
  position: absolute;
  top: calc(var(--safe-t) + 10px);
  right: calc(var(--safe-r) + 10px);
  width: var(--tap);
  height: var(--tap);
  display: grid;
  place-items: center;
  font: inherit;
  font-size: 30px;
  line-height: 1;
  color: var(--ink);
  background: var(--paper);
  border: var(--line-w) solid var(--ink);
  border-radius: 50%;
  cursor: pointer;
}

.media__label {
  margin: 0 0 8px;
  font-size: 13px;
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
}

/* The native control, given the page's border and shadow rather than being
   re-skinned. A hand-built audio player is a scrubber, a buffering state and a
   keyboard model to get wrong; the platform's already works, already announces
   itself to VoiceOver, and already looks like something a phone can play. */
.media__audio {
  display: block;
  width: 100%;
  min-width: 0;
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius-pill);
  box-shadow: var(--shadow-sm);
  background: var(--chip);
}

/* ── map ───────────────────────────────────────────────── */

/* The map is an object like everything else here: bordered, shadowed, sitting
   on white. It carries no basemap and makes no request — see the comment on
   renderMap() in app.js for why that is a feature and not a shortcut. */
.map {
  margin: 0 0 20px;
  padding: 14px 14px 10px;
  background: var(--paper);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}
/* The basemap image and the pin overlay are STACKED SIBLINGS, not nested — the
   reason is in renderMap(), and it is ISC-111, not neatness. The canvas carries
   the aspect ratio (set inline from the baked frame's own dimensions) so both
   children can fill it absolutely and stay in register at every viewport width.
   Before the basemap existed the aspect came from the SVG viewBox; it now has
   to come from the box BOTH of them share, or they can disagree. */
.map__canvas {
  display: block;
  position: relative;
  overflow: hidden;
  border-radius: 2px;
  background: var(--chip);          /* visible while the image decodes, and the
                                       whole surface when none has been baked */
}
.map__base,
.map__svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
/* The image was rendered at this exact aspect, so `cover` never actually
   crops — it is here so that a stale basemap-meta.js can only letterbox the
   pins slightly rather than stretch the city. */
.map__base { object-fit: cover; }
.map__note {
  margin: 10px 0 0;
  font-size: 12px;
  line-height: 1.4;
  font-weight: 600;
  color: var(--ink-soft);
}
.map__note strong {
  display: block;
  font-weight: 800;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink);
}

.offline-warn {
  margin: 0 0 16px;
  padding: 12px 14px;
  font-size: 14px;
  font-weight: 700;
  color: var(--ink);
  background: var(--yellow);
  border: var(--line-w) solid var(--ink);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
}

/* vector-effect keeps strokes at a constant screen width no matter how the
   viewBox scales — without it a 3px border becomes hairline on a wide phone
   and a slab on a narrow one, because the whole svg is scaled to fit. */
.map__route,
.map__dot,
.map__scale,
.map__leader,
.map__true {
  vector-effect: non-scaling-stroke;
}

/* Leader + true-position dot. Deliberately quiet: the badge is what you read,
   the dot is what you trust. */
.map__leader { stroke: var(--ink); stroke-width: 1.2; opacity: 0.55; }
.map__true { fill: var(--ink); stroke: none; }
/* 0.45 was tuned against blank paper. Over rendered streets a 45%-opacity ink
   dash reads as another road rather than as the route, so it goes up — but not
   to full, because it must stay legible AS an overlay and not compete with the
   badges it connects. Unverified against a real render; see the ISC. */
.map__route {
  fill: none;
  stroke: var(--ink);
  stroke-width: 2;
  stroke-dasharray: 5 5;
  stroke-linejoin: round;
  opacity: 0.7;
}
/* The tap target: invisible, and larger than the badge it sits under. `fill:
   transparent` rather than `fill: none` is load-bearing — an unfilled SVG
   shape is not hit-tested at all, so `none` would draw nothing AND catch
   nothing, which is the whole element doing neither of its jobs. */
.map__hit {
  fill: transparent;
  stroke: none;
}

.map__pin { cursor: pointer; }
/* The ring is drawn on the group, so it encloses the whole tap target rather
   than the badge alone — on a photographic basemap a ring tight around a small
   dot is easy to lose against the buildings behind it. */
.map__pin:focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 2px;
}

.map__dot {
  fill: var(--spine, var(--purple));
  stroke: var(--ink);
  stroke-width: 2.5;
}
.map__pin--done .map__dot { fill: var(--green); }
.map__label {
  font-family: inherit;
  font-size: 5.4px;                 /* svg user units, scaled with the viewBox */
  font-weight: 900;
  fill: var(--ink);
}
.map__scale { stroke: var(--ink); stroke-width: 2.5; stroke-linecap: square; }
.map__scale-text {
  font-family: inherit;
  font-size: 4.6px;
  font-weight: 800;
  fill: var(--ink);
}
/* A scale bar drawn straight onto map imagery is a smudge — a hairline rule
   over a grey building block reads as neither. The plate is the standard
   cartographic answer: give the measurement its own ground. */
.map__scale-plate {
  fill: var(--paper);
  fill-opacity: 0.82;
  stroke: var(--ink);
  stroke-width: 1;
  stroke-opacity: 0.35;
}

/* The place-list block that stood here is gone with the list itself (D-104).
   Its minmax(0, 1fr) + min-width:0 pairing is NOT lost — that guard lives on
   in `.stations`, which is where ISC-25's horizontal blowout actually
   happened and where ISC-74 still pins it. */

/* ── navigation links ──────────────────────────────────── */

/* These leave the app, so they are styled as chips rather than as the primary
   buttons — the primary action on any screen is still the quest itself. */
.navlink {
  display: inline-flex;
  align-items: center;
  min-height: 34px;
  padding: 5px 11px;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--ink);
  background: var(--chip);
  border: 2px solid var(--ink);
  border-radius: var(--radius-pill);
  touch-action: manipulation;
}
.navlink:active { transform: translate(2px, 2px); }

/* A chip that does not leave the app. `See the wall` is a real button rather
   than an anchor — from a standalone PWA an href="#" pushes a history entry
   with nowhere to go back to — and a <button> inherits the UA font stack rather
   than the page's, so it has to be told. */
button.navlink {
  font-family: inherit;
  cursor: pointer;
}

/* On the station screen there is room to be a real button rather than a chip. */
.station__nav {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 0 0 24px;
}
.station__nav .navlink {
  flex: 1 1 auto;
  justify-content: center;
  min-height: var(--tap);
  font-size: 14px;
  border-radius: var(--radius);
  border-width: var(--line-w);
  box-shadow: var(--shadow-sm);
}
.station__nav .navlink:active {
  transform: translate(3px, 3px);
  box-shadow: var(--shadow-press);
}

/* The help hand-off under the station list. The dashed rule is the same
   separator the done screen puts above its ask — this is an aside beneath the
   list, not another station. Soft ink is 8.9:1 on white, so it reads in
   sunlight while sitting a step below the station titles. The chip gets 44px
   here rather than the 34px it has inside a place row: nothing else on this
   screen is competing for the space, and a control someone reaches for while
   lost should not be the smallest target on the page. */
.helpline {
  margin-top: 22px;
  padding-top: 18px;
  border-top: 2px dashed var(--ink);
}
.helpline__text {
  /* No trailing margin any more: the chip that used to sit under this line was
     removed with the Telegram hand-off (D-87), so this paragraph is the whole
     block and a 12px gap below it would be padding around nothing. */
  margin: 0;
  font-size: 14px;
  line-height: 1.45;
  color: var(--ink-soft);
}

.btn-map { background: var(--yellow); }

/* ── focus & forced colours ────────────────────────────── */

/* One ring for every control. The previous design killed the input outline and
   replaced it with a border-colour change; this one replaces it with nothing
   the OS can strip. `outline` is the only indicator that survives Windows High
   Contrast — box-shadow and background are both discarded there — so the ring
   has to be an outline, not a shadow, or keyboard users lose focus entirely in
   forced-colors mode. Ink on purple is 3.43:1, clearing SC 1.4.11's 3:1 bar on
   the brand bands; on white it is 21:1. */
:where(button, [href], input, select, textarea, [tabindex]):focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 3px;
}

@media (forced-colors: active) {
  /* Deliberately NOT forced-color-adjust:none. Somebody running forced colours
     has chosen a palette they can actually read; overriding it to protect this
     one would be backwards. Every object here carries a real border as well as
     its shadow, so when the OS discards shadows and repaints fills, the
     borders still carry every shape — nothing in this design conveys state
     through shadow alone. Two adjustments are worth making: paint the ring in
     the system highlight colour, and drop the decorative shapes, which carry
     no meaning and would otherwise repaint as large solid blocks. */
  :where(button, [href], input, select, textarea, [tabindex]):focus-visible {
    outline-color: Highlight;
  }
  .deco { display: none; }
}

/* ── motion ────────────────────────────────────────────── */

@media (prefers-reduced-motion: no-preference) {
  .btn, .btn-back, .station {
    transition: transform 80ms ease-out, box-shadow 80ms ease-out;
  }
}
