/*
 * Visual language.
 *
 * Apple's, deliberately: the system font stack, iOS system colours, iOS spacing and
 * radii, and Apple's own easing curve. The app is Apple-styled on both platforms — but
 * it is built to work identically on Android, so the font stack falls through to Roboto
 * rather than dropping to something unpleasant, and nothing here relies on a WebKit-only
 * feature.
 *
 * Dark mode is not decoration. Someone checking a 5am departure is doing it in a dark
 * room, and a white screen at that moment is genuinely unpleasant.
 */

:root {
  --accent: #5b4fe8;
  --green: #34c759;
  --orange: #ff9500;
  --red: #ff3b30;

  --bg: #f2f2f7;
  --bg-deep: #e9e9ef;
  --surface: rgba(255, 255, 255, 0.86);
  --surface-solid: #ffffff;
  --separator: rgba(60, 60, 67, 0.13);
  --label: #1c1c1e;
  --label-2: rgba(60, 60, 67, 0.62);
  --label-3: rgba(60, 60, 67, 0.32);
  --fill: rgba(120, 120, 128, 0.12);

  --engrave-ink: rgba(60, 60, 67, 0.07);
  --aurora: 0.75;

  --radius: 14px;
  --radius-lg: 20px;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 10px 30px rgba(0, 0, 0, 0.08);
  --blur: saturate(180%) blur(20px);

  --font: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display',
          'Segoe UI Variable Text', Roboto, 'Helvetica Neue', system-ui, sans-serif;
  --ease: cubic-bezier(0.32, 0.72, 0, 1);

  --top: env(safe-area-inset-top, 0px);
  --bottom: env(safe-area-inset-bottom, 0px);

  color-scheme: light;
}

:root[data-theme='dark'] {
  --bg: #000000;
  --bg-deep: #0a0a0c;
  --surface: rgba(28, 28, 30, 0.78);
  --surface-solid: #1c1c1e;
  --separator: rgba(84, 84, 88, 0.5);
  --label: #ffffff;
  --label-2: rgba(235, 235, 245, 0.62);
  --label-3: rgba(235, 235, 245, 0.32);
  --fill: rgba(120, 120, 128, 0.24);

  --accent: #6c5ce8;
  --green: #30d158;
  --orange: #ff9f0a;
  --red: #ff453a;

  --engrave-ink: rgba(235, 235, 245, 0.07);
  --aurora: 0.6;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 10px 32px rgba(0, 0, 0, 0.6);

  color-scheme: dark;
}

@media (prefers-color-scheme: dark) {
  :root[data-theme='auto'] {
    --bg: #000000;
    --bg-deep: #0a0a0c;
    --surface: rgba(28, 28, 30, 0.78);
    --surface-solid: #1c1c1e;
    --separator: rgba(84, 84, 88, 0.5);
    --label: #ffffff;
    --label-2: rgba(235, 235, 245, 0.62);
    --label-3: rgba(235, 235, 245, 0.32);
    --fill: rgba(120, 120, 128, 0.24);

    --accent: #6c5ce8;
    --green: #30d158;
    --orange: #ff9f0a;
    --red: #ff453a;

    --engrave-ink: rgba(235, 235, 245, 0.07);
    --aurora: 0.6;
    --shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 10px 32px rgba(0, 0, 0, 0.6);

    color-scheme: dark;
  }
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

/*
 * The `hidden` attribute must win.
 *
 * Browsers implement it as `display: none` in the UA stylesheet, which any author rule
 * setting `display` silently overrides — so a `hidden` element given `display: flex`
 * stays fully visible. That is how a white full-screen overlay ended up painted over the
 * landing page. Declared once, here, so no future layout rule can reintroduce it.
 */
[hidden] { display: none !important; }

/*
 * Two backgrounds, and the difference matters at the bottom edge.
 *
 * On iOS the strip below the layout viewport — where the home indicator sits — is painted
 * by the *canvas*, and the canvas takes its colour from `html` when `html` has one. It is
 * not part of the page: nothing positioned inside the document can reach it, which is why
 * a backstop drawn beneath the drawer never covered it however tall it was made.
 *
 * So `html` carries the drawer's colour and `body` carries the page's. The body box fills
 * the viewport, so the page reads as it always did; below it, where only the canvas
 * shows, the colour is already the bar's — and the seam that read as the drawer floating
 * above a black band cannot occur, whatever the browser reports for the safe-area inset.
 */
html {
  -webkit-text-size-adjust: 100%;
  height: 100%;
  background: var(--surface-solid);
}

body {
  margin: 0;
  min-height: 100%;
  font-family: var(--font);
  font-size: 17px;
  line-height: 1.45;
  letter-spacing: -0.011em;
  color: var(--label);
  /*
   * The bar's colour, not the page's.
   *
   * The page's own background is painted by `.backdrop`, a fixed element covering the
   * viewport — so this colour is never visible inside the page at all. It shows only
   * where the document does not reach: the strip an installed app leaves below the
   * layout viewport, which several iOS versions fill by sampling the body rather than
   * the manifest.
   *
   * Setting it to the drawer's surface means that strip cannot contrast with the bar
   * above it, whatever the device's insets turn out to be. The page is unaffected.
   */
  background: var(--surface-solid);
  -webkit-font-smoothing: antialiased;
  overscroll-behavior-y: none;
}

h1, h2, h3 { margin: 0; letter-spacing: -0.021em; line-height: 1.16; font-weight: 700; }
p { margin: 0; }
.muted { color: var(--label-2); }

/* ───────────────────────── backdrop ─────────────────────────
 * A guilloché lattice of the sort printed on banknotes and old railway tickets — the
 * heritage this app draws on. Built from gradients rather than an image, so it costs no
 * bandwidth and stays sharp at any density. Kept faint: it sits behind a form where
 * people check a booking reference, and must never compete with it. */

.backdrop {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
  background: radial-gradient(120% 90% at 50% -10%, var(--bg) 0%, var(--bg-deep) 70%);
}

.engrave {
  position: absolute;
  inset: -20%;
  background-image:
    repeating-linear-gradient(58deg,  var(--engrave-ink) 0 1px, transparent 1px 9px),
    repeating-linear-gradient(-58deg, var(--engrave-ink) 0 1px, transparent 1px 9px),
    repeating-linear-gradient(0deg,   var(--engrave-ink) 0 1px, transparent 1px 26px);
  mask-image: radial-gradient(90% 62% at 50% 18%, #000 0%, transparent 74%);
  -webkit-mask-image: radial-gradient(90% 62% at 50% 18%, #000 0%, transparent 74%);
}

/* Blurred colour fields.
 *
 * Static, deliberately. An earlier version drifted them slowly, which looked handsome
 * and kept the GPU compositing a large blurred layer forever — on a phone that means
 * measurable battery drain and a warm device, for decoration nobody asked for. A
 * ticket app that heats the phone in your pocket has failed at something more important
 * than looking pretty. */
.aurora {
  position: absolute;
  border-radius: 50%;
  filter: blur(64px);
  opacity: var(--aurora);
}

.aurora-1 {
  width: 52vw; height: 52vw; min-width: 320px; min-height: 320px;
  top: -16vw; left: -12vw;
  background: radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent), transparent 68%);
}

.aurora-2 {
  width: 44vw; height: 44vw; min-width: 280px; min-height: 280px;
  top: -8vw; right: -14vw;
  background: radial-gradient(circle, color-mix(in srgb, #5856d6 26%, transparent), transparent 68%);
}

/* Mobile GPUs pay disproportionately for a wide blur radius, and the visual difference
   at this scale is negligible. */
@media (max-width: 640px) { .aurora { filter: blur(44px); } }

/* The barcode is the one screen where nothing may compete for the GPU, and the backdrop
   is invisible behind it anyway. */
.scan ~ .backdrop, body:has(.scan:not([hidden])) .backdrop { display: none; }

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

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

.screen {
  max-width: 620px;
  margin: 0 auto;
  animation: rise 0.34s var(--ease) both;
}

/*
 * On a wide screen, spread out.
 *
 * A PWA is not a phone app that happens to run in a browser, and a single 620px column
 * stranded in the middle of a desktop display looks like one that forgot where it was.
 * The column is right for reading a pass — a boarding pass is a portrait object, and
 * stretching one across a monitor would be worse — but the *list* is a grid, and a grid
 * should use the room it is given.
 *
 * Only the home list widens. Review, help and settings stay in a column, because those
 * are reading tasks and a 1400px line of prose is unreadable at any size.
 */
@media (min-width: 900px) {
  #screen-home { max-width: 1100px; }

  #home-body .list-block,
  #home-body .past-block {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
    gap: 14px;
    align-items: start;
  }

  /* The section label spans the grid rather than taking a cell of its own. */
  #home-body .section-label,
  #home-body .past-block > summary { grid-column: 1 / -1; }

  /* A swipe row is a grid item now, so its own margin would double the gap. */
  #home-body .list-block .swipe,
  #home-body .past-block .swipe { margin-bottom: 0; }

  /* The next pass keeps its prominence: full width, and larger than the rest. */
  #home-body .next-block { max-width: 100%; }

  /*
   * The bar spans the window, its contents stay with the content.
   *
   * A navigation bar clipped to the content column reads as a phone app pasted onto a
   * desktop: the surface stops mid-screen and the brand floats away from the corner.
   * The bar is now full width — as a bar should be — while what sits inside it lines up
   * with the cards beneath, which is what makes the two feel like one layout.
   */
  #screen-home > .bar {
    max-width: none;
    margin-inline: calc((100% - 100vw) / 2);
    padding-inline: max(12px, calc((100vw - 1100px) / 2));
  }
}

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

/*
 * A sheet — help and settings — is a column: a fixed bar, a scrolling body, and a pager
 * pinned to the bottom.
 *
 * Without an explicit height the body has nothing to scroll within, so a short page
 * collapsed and a long one ran past the bottom of the window with the pager over the
 * text. Using dvh rather than vh matters on iOS, where the address bar changes the
 * viewport as you scroll and vh does not follow it.
 */
.screen.sheet {
  display: flex;
  flex-direction: column;
  height: 100dvh;
  max-height: 100dvh;
  overflow: hidden;
}

.screen.sheet .bar { flex: 0 0 auto; }

.screen.sheet .help-body,
.screen.sheet .settings-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
}

.screen.sheet .pager { flex: 0 0 auto; }

/* ───────────────────────── bar ───────────────────────── */

/* A stated height, rather than whatever the tallest control happens to be.
   The bar was 59px on the home and pass screens and 57.6px everywhere else, because
   those two hold 38px icon buttons while the rest hold a text button that measures 36.6.
   Nothing chose either number. A bar that changes height between screens makes the whole
   page jump on every navigation, which reads as the app shrinking. */
.bar > * { min-height: 38px; }

.bar {
  position: sticky;
  top: 0;
  z-index: 20;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 8px;
  padding: calc(var(--top) + 10px) 12px 10px;
  background: var(--surface);
  backdrop-filter: var(--blur);
  -webkit-backdrop-filter: var(--blur);
  border-bottom: 1px solid var(--separator);

  /*
   * Its own layer, so the blur is composited once rather than recalculated as content
   * moves beneath it. Without this, iOS Safari blurs a sticky bar unevenly while
   * scrolling — content shows through in patches and the bar looks broken.
   */
  transform: translateZ(0);
  will-change: backdrop-filter;
  isolation: isolate;
}

/*
 * Where the blur cannot be trusted, a solid bar.
 *
 * A translucent bar that renders half-blurred is worse than an opaque one: the effect is
 * decoration, and legibility is not.
 */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .bar { background: var(--surface-solid); }
}

/*
 * The app's own bar, where the name sits left with its mark beside it.
 *
 * Sheets keep the centred title with a Back button — that is a modal convention. A
 * home screen is not modal, and an app names itself in the corner.
 */
.app-bar { grid-template-columns: auto 1fr; }

/*
 * The brand: the mark and the name, set as a logotype rather than as a heading.
 *
 * As plain bold text it read as a page title that happened to say Stub. What separates
 * a wordmark from a heading is mostly tracking and weight — a name set tight and heavy
 * reads as a considered mark, while the same word at default spacing reads as a label.
 *
 * The mark scales with the name rather than sitting at a fixed pixel size, so the pair
 * grows together on a large screen and stays proportionate on a phone.
 */
.bar-brand {
  grid-column: 1;
  display: flex;
  align-items: center;
  gap: 0.42em;
  font-size: clamp(20px, 2.4vw, 26px);
  font-weight: 750;
  letter-spacing: -0.028em;
  line-height: 1;
}

/* The name is set plainly.
   It briefly carried a vertical gradient clipped to the glyphs, which relied on
   `-webkit-text-fill-color: transparent` and rendered the word near-black in the app bar
   — invisible against the dark surface. The mark beside it does the branding; the word
   only has to be readable. */
.bar-brand .name {
  color: currentColor;
}

.bar-mark {
  display: flex;
  width: 1.35em;
  height: 1.35em;
}

/* The mark carried through the import flow.
   Moving to Add a ticket replaced the brand lockup with a plain 17px title, so the app's
   own mark vanished at exactly the moment the user is handing it a file — which is when
   they most want to be sure of what they are handing it to. The pass screen keeps no
   mark deliberately: there the ticket is the subject, and the brand would compete. */
.bar-mark.small {
  width: 20px;
  height: 20px;
  margin-right: 7px;
  flex: none;
}

.bar-title .bar-mark.small { vertical-align: -4px; display: inline-flex; }

.bar-mark svg { width: 100%; height: 100%; border-radius: 7px; }

.app-bar .bar-actions { grid-column: 2; }

.bar-title {
  grid-column: 2;
  font-size: 17px;
  font-weight: 600;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.bar > .icon-button:first-child, .bar > .text-button:first-child { justify-self: start; }
.bar-actions { grid-column: 3; justify-self: end; display: flex; gap: 2px; }
.bar-spacer { display: inline-block; width: 34px; }

.icon-button {
  width: 38px; height: 38px;
  display: grid; place-items: center;
  border: 0; border-radius: 50%;
  background: none;
  color: var(--accent);
  cursor: pointer;
  transition: background 0.2s var(--ease), transform 0.12s var(--ease), opacity 0.2s;
}

.icon-button:hover { background: var(--fill); }
.icon-button:active { transform: scale(0.9); }
.icon-button:disabled { opacity: 0.3; }

.text-button {
  border: 0;
  background: none;
  color: var(--accent);
  font: inherit;
  font-size: 17px;
  padding: 6px 4px;
  cursor: pointer;
}

.text-button.strong { font-weight: 600; }
.text-button:disabled { opacity: 0.35; }

.text-link {
  border: 0; background: none;
  color: var(--accent);
  font: inherit; font-size: 15px;
  cursor: pointer;
  padding: 10px;
}

/* ───────────────────────── landing ───────────────────────── */

/*
 * Credit, centred at the foot of the landing page.
 *
 * Set quiet deliberately: it is a signature, not a claim on the page. Spaced well clear
 * of the last control so it reads as a footer rather than another thing to tap.
 */
/*
 * A beating heart.
 *
 * Animated rather than a GIF: it stays sharp at any size, costs nothing to download,
 * inherits the surrounding text colour, and adds no file to cache offline. Two beats
 * then a rest, which is what a heartbeat actually does — a steady pulse reads as a
 * loading spinner.
 */
.heartbeat {
  display: inline-block;
  animation: heartbeat 1.6s ease-in-out infinite;
  transform-origin: center;
  will-change: transform;
}

@keyframes heartbeat {
  0%   { transform: scale(1); }
  10%  { transform: scale(1.22); }
  20%  { transform: scale(1); }
  30%  { transform: scale(1.16); }
  42%  { transform: scale(1); }
  100% { transform: scale(1); }
}

/* Motion can be unwelcome, and a decorative pulse is never worth overriding that. */
@media (prefers-reduced-motion: reduce) {
  .heartbeat { animation: none; }
}

/* A kind mark on cards and passes that have no route to draw. */
.card-kind, .pass-kind {
  display: block;
  margin-bottom: 6px;
  opacity: 0.75;
}

.pass-kind { margin-bottom: 8px; }

/*
 * Help pages slide as they change.
 *
 * Without this a swipe replaced the text instantly and it was hard to tell anything had
 * happened — the gesture worked and felt broken, which is worse than not offering it.
 */
.help-body.turning-forward { animation: page-forward 0.26s var(--ease); }
.help-body.turning-back { animation: page-back 0.26s var(--ease); }

@keyframes page-forward {
  from { opacity: 0; transform: translateX(22px); }
  to { opacity: 1; transform: none; }
}

@keyframes page-back {
  from { opacity: 0; transform: translateX(-22px); }
  to { opacity: 1; transform: none; }
}

.help-action { margin: 18px 0 6px; }

.help-button {
  display: inline-block;
  padding: 12px 22px;
  border-radius: 12px;
  background: var(--accent);
  color: #fff;
  font-size: 15px;
  font-weight: 600;
  text-decoration: none;
}

.help-list { margin: 0 0 14px; padding-left: 20px; }
.help-list li { font-size: 15px; color: var(--label-2); margin: 8px 0; line-height: 1.5; }

/* Where each detail came from, shown behind the ticket's own menu. */
.action-panel.sources { background: var(--surface-solid); }

.sources-body {
  max-height: 60vh;
  overflow-y: auto;
  padding: 20px 20px 6px;
}

.sources-body h2 { font-size: 17px; margin-bottom: 4px; }
.sources-note { font-size: 13px; color: var(--label-2); margin-bottom: 14px; }

/*
 * Press feedback.
 *
 * A card that gives under a thumb and springs back is most of what separates an app from
 * a web page. The curve matters: easing out faster than in is what reads as physical
 * rather than as an animation.
 *
 * `.pressing` is set from JavaScript because iOS Safari does not reliably apply `:active`
 * to arbitrary elements — the one platform this most needs to work on.
 */
.card,
.action,
.row,
.option,
.primary,
.show-code,
.icon-button,
.text-button,
.chip,
.dot {
  transition:
    transform 0.34s cubic-bezier(0.2, 0.9, 0.3, 1),
    box-shadow 0.34s cubic-bezier(0.2, 0.9, 0.3, 1),
    filter 0.2s ease,
    background-color 0.2s ease;
  -webkit-tap-highlight-color: transparent;
}

/*
 * Press feedback, as a scale factor rather than a whole transform.
 *
 * `.card.pressing { transform: scale(...) }` replaced the card's transform outright, and
 * a card is "pressing" for the whole of a drag — so the swipe's translation was thrown
 * away on every pointer move and the card never left its place. Composing through a
 * custom property lets a card be both pressed and dragged, which is exactly what happens
 * when a finger goes down and then moves.
 */
.card.pressing {
  --press: 0.968;
  filter: brightness(0.97);
}

/* Not while swiping: the card is following a finger, not being pressed. */
.swipe.dragging > .card.pressing {
  --press: 1;
  filter: none;
}

.action.pressing,
.row.pressing,
.option.pressing {
  background: var(--fill);
  transform: scale(0.995);
}

.primary.pressing,
.show-code.pressing {
  transform: scale(0.972);
  filter: brightness(0.94);
}

.icon-button.pressing,
.text-button.pressing,
.chip.pressing {
  transform: scale(0.9);
  opacity: 0.65;
}

.dot.pressing { transform: scale(1.5); }

/*
 * Cards lift rather than sit flat.
 *
 * A pass is a physical object in the user's mind, and a shadow that deepens toward the
 * bottom is what makes a rectangle read as one lying on a surface.
 */
/*
 * Swipe to archive or delete.
 *
 * The card sits in a wrapper that holds two action panels behind it, one either side.
 * The card itself is translated; the panels never move, so the card reads as sliding
 * off to reveal what is underneath — the same model as Mail, and the reason it feels
 * physical rather than like two views cross-fading.
 *
 * The wrapper is what carries the shadow and the radius during a swipe. The card clips
 * its own corners, so without this the panels would show as hard rectangles poking out
 * from behind a rounded card.
 */
.swipe {
  position: relative;
  border-radius: var(--radius-lg);
  overflow: hidden;
  touch-action: pan-y;
}

/*
 * The gap between cards belongs to the wrapper once a card is wrapped, not to the card.
 * Left on the card, a row collapsing after a swipe would close its own height but leave
 * its margin behind, so the list would shuffle up and stop 12px short.
 */
.swipe { margin-bottom: 12px; }
.swipe > .card { margin-bottom: 0; }

.swipe-action {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 50%;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 22px;
  font-size: 14px;
  font-weight: 600;
  color: #fff;
  /* Behind the card, which is the whole illusion. */
  z-index: 0;

  /*
   * Hidden until a swipe is actually under way.
   *
   * The card is not opaque during its entrance animation, so on every load both panels
   * showed straight through it — a red and green flash across the list before the cards
   * faded in. Revealing them only while dragging also means the panel that appears is
   * the one the gesture is heading towards, rather than both at once.
   */
  opacity: 0;
  transition: opacity 0.12s ease;
}

.swipe.dragging .swipe-action.armed { opacity: 1; }

.swipe-action.archive { left: 0; justify-content: flex-start; background: var(--green); }
.swipe-action.delete { right: 0; justify-content: flex-end; background: var(--red); }

.swipe > .card {
  position: relative;
  z-index: 1;
  width: 100%;
  /* The translation itself lives in the shared `.card` transform, so that the press
     scale can compose with it rather than overwrite it. */
}

.swipe.settling > .card { transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1); }

/* Committed: the card leaves, then the row collapses so the list closes the gap. */
.swipe.leaving {
  transition: height 0.26s ease, opacity 0.26s ease, margin 0.26s ease;
  height: 0 !important;
  opacity: 0;
  margin-bottom: 0 !important;
}

/* The card is still travelling while the row folds, so it needs its own transition. */
.swipe.leaving > .card { transition: transform 0.26s cubic-bezier(0.4, 0, 1, 1); }

@media (prefers-reduced-motion: reduce) {
  .swipe.settling > .card { transition: none; }
  .swipe.leaving { transition: none; }
}

/*
 * Depth on the cards.
 *
 * A pass is a physical object in the user's mind, and three things make a rectangle read
 * as one: a shadow that is tight and dark near the surface but wide and soft further out,
 * a bright hairline along the top edge where light would catch it, and a faint sheen
 * across the upper half. Any one alone looks like a coloured box.
 *
 * Consolidated here because a later `.card` rule was overriding an earlier one, leaving
 * the shadows defined and unused.
 */
.card {
  box-shadow:
    0 1px 1px rgba(0, 0, 0, 0.10),
    0 2px 4px rgba(0, 0, 0, 0.08),
    0 8px 18px -6px rgba(0, 0, 0, 0.22),
    inset 0 1px 0 rgba(255, 255, 255, 0.18);
  position: relative;
  overflow: hidden;
  isolation: isolate;
}

/* The sheen. Sits above the background and below the text. */
.card::after {
  content: '';
  position: absolute;
  inset: 0 0 auto;
  height: 62%;
  background: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0.14) 0%,
    rgba(255, 255, 255, 0.05) 45%,
    transparent 100%
  );
  pointer-events: none;
  z-index: -1;
}

.card.large {
  box-shadow:
    0 1px 1px rgba(0, 0, 0, 0.10),
    0 4px 8px rgba(0, 0, 0, 0.10),
    0 18px 34px -12px rgba(0, 0, 0, 0.30),
    inset 0 1px 0 rgba(255, 255, 255, 0.22);
}

.card.pressing {
  box-shadow:
    0 1px 1px rgba(0, 0, 0, 0.12),
    0 2px 5px -2px rgba(0, 0, 0, 0.22),
    inset 0 1px 0 rgba(255, 255, 255, 0.12);
}

/* The same treatment on the pass itself, which is the same object made larger. */
.pass-card {
  box-shadow:
    0 1px 1px rgba(0, 0, 0, 0.10),
    0 4px 10px rgba(0, 0, 0, 0.10),
    0 22px 40px -14px rgba(0, 0, 0, 0.32),
    inset 0 1px 0 rgba(255, 255, 255, 0.20);
  overflow: hidden;
}

/* A card sliding in as the list is built, each a little after the last. */
@keyframes card-in {
  from { opacity: 0; transform: translateY(10px) scale(0.99); }
  to { opacity: 1; transform: none; }
}

/*
 * The entrance animation belongs to the wrapper, not to the card.
 *
 * It animates `transform`, and a CSS animation outranks a normal declaration — with
 * `both` fill it holds `transform: none` indefinitely after finishing. On the card that
 * silently defeated the swipe's own `translateX` for good: the card never moved a pixel,
 * while the gesture itself worked, so a swipe deleted a pass with no animation at all.
 *
 * The stagger has to move with it for a second reason. The card is no longer the first
 * child of anything — two action panels precede it — so `.card:nth-child(2)` and friends
 * matched the same position on every row and every card took the same delay.
 */
.card { animation: none; }
.swipe { animation: card-in 0.42s cubic-bezier(0.2, 0.9, 0.3, 1) both; }
.swipe:nth-child(2) { animation-delay: 0.045s; }
.swipe:nth-child(3) { animation-delay: 0.09s; }
.swipe:nth-child(4) { animation-delay: 0.13s; }
.swipe:nth-child(n+5) { animation-delay: 0.16s; }

/*
 * The entrance animation has to be dropped before a row can leave, for the same reason
 * it had to be taken off the card: `both` holds its final opacity, and an animation beats
 * a transition, so a row would collapse without ever fading.
 */
.swipe.leaving { animation: none; }

@media (prefers-reduced-motion: reduce) {
  .swipe { animation: none; }
  .card { animation: none; }
  .card.pressing,
  .primary.pressing,
  .show-code.pressing,
  .icon-button.pressing,
  .text-button.pressing,
  .chip.pressing,
  .dot.pressing { transform: none; }
}

/*
 * The artwork band at the top of a pass.
 *
 * Drawn from the ticket's own category and colour, so a flight looks like sky and a
 * cinema booking like a darkened auditorium. Sits behind the route, which is why the
 * composition carries a scrim — text over artwork has to stay readable.
 */
.pass-art {
  position: absolute;
  top: 0; left: 0; right: 0;
  /* Only as tall as the route block above the fields, or it covers their labels. */
  height: 92px;
  overflow: hidden;
  border-radius: var(--radius) var(--radius) 0 0;
  pointer-events: none;
  /* Fades out rather than ending on a hard line across the card. */
  -webkit-mask-image: linear-gradient(to bottom, #000 62%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 62%, transparent 100%);
}

.pass-art svg {
  width: 100%;
  height: 100%;
  display: block;
  /* The composition is drawn at Wallet's strip ratio, which is far wider than the band
     it fills here; without this it is letterboxed against the card. */
  object-fit: cover;
  object-position: center;
}

.pass-card { position: relative; }
.pass-card .pass-route { position: relative; z-index: 1; }

/* Links and credit share one row: navigation left, signature right — the arrangement a
   reader expects at the foot of a page. Stacked and left-aligned they read as one ragged
   block with the credit looking like a fourth link. Below the breakpoint there is no room
   for two columns, so they stack and centre, which keeps the credit deliberate rather
   than orphaned under the links. */
.landing-footer {
  margin-top: 38px;
  padding-top: 20px;
  border-top: 1px solid var(--separator);
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px 24px;
}

.landing-footer p {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--label-2);
}

/* Links sit above the credit, with real space between the two. They were a line apart and
   read as one crowded block rather than as navigation and a signature.
   Contact points at the contact form on Tejaswi's own site rather than a mailto:, which
   would expose an address to scrapers and open a mail client the reader may not have. */
.landing-links {
  display: flex;
  gap: 12px;
  align-items: baseline;
  flex-wrap: wrap;
  font-size: 14px;
}

.landing-links a,
.landing-links .link-button { color: #a99bff; text-decoration: none; }
.landing-links a:hover,
.landing-links .link-button:hover { text-decoration: underline; }
.landing-links span { opacity: 0.32; }

.landing-credit {
  /* Set on the element rather than fighting the shared `p` rule with !important, which
     the earlier version did. */
  font-size: 12.5px;
  color: rgba(235, 235, 245, 0.38);
}

.landing-links + .landing-credit { margin-top: 0; }

@media (max-width: 620px) {
  .landing-footer { justify-content: center; text-align: center; }
  .landing-links { justify-content: center; }
  .landing-footer .landing-credit { flex-basis: 100%; margin-top: 6px; }
}

.app-mark img { border-radius: 22%; box-shadow: 0 8px 26px rgba(0, 0, 0, 0.16); }

/* ───────────────────────── landing ─────────────────────────
   Two bands, light over dark, with the drawn phone straddling the seam.

   The page previously centred everything in one narrow column — mark, headline, button,
   list — which reads as an install gate rather than a welcome. The split is what makes it
   a product page: the claims sit in the light, the call to action in the dark, and the
   phone crosses both so the two bands read as one composition.

   The light band is light in *both* themes. This is the only such surface in the app, and
   it is deliberate: the landing page is a shop window rather than a screen someone stares
   at in a dark aeroplane cabin, and the split needs real contrast to work at all. */

.landing {
  padding: 0;
  /* The landing page manages its own width. `.screen` caps every screen at a readable
     column, which is right for the app but wrong here: the light and dark bands have to
     reach the window edges or the composition reads as two floating panels. Each band
     centres its own content, so the text column stays the same width regardless. */
  max-width: none;
}

.landing-band {
  max-width: 1080px;
  margin: 0 auto;
  padding: 0 clamp(20px, 5vw, 56px);
}

.landing-top {
  background: linear-gradient(168deg, #ffffff 0%, #f3f2fb 58%, #eceafa 100%);
  color: #1a1a22;
  padding: calc(clamp(30px, 6vw, 60px) + var(--top)) 0 clamp(30px, 6vw, 64px);
}

.landing-bottom {
  background: linear-gradient(180deg, #171734 0%, #101026 100%);
  color: #ffffff;
  padding: clamp(30px, 6vw, 58px) 0 clamp(34px, 7vw, 70px);
}

/* The install instructions are a panel on a dark ground here, not the app's translucent
   surface — which sampled the page behind it and rendered as pale grey haze over the
   band, with the text inside barely legible.

   Scoped to both dark bands. It was written for .landing-bottom alone, and when the call
   to action moved down the page the panel took the app's translucent styling with it:
   a pale card carrying white text, which is the one combination that cannot be read. */
.landing-bottom .install-steps,
.landing-foot .install-steps {
  background: rgba(255, 255, 255, 0.055);
  border: 1px solid rgba(255, 255, 255, 0.1);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

.landing-bottom .install-lead,
.landing-foot .install-lead { color: #fff; }
.landing-bottom .install-body,
.landing-bottom .install-steps ol,
.landing-foot .install-body,
.landing-foot .install-steps ol { color: rgba(235, 235, 245, 0.66); }
.landing-bottom .install-steps strong,
.landing-foot .install-steps strong { color: #fff; }
.landing-bottom .ghost-button,
.landing-foot .ghost-button { border-color: rgba(255, 255, 255, 0.2); color: #a99bff; }

.landing-foot {
  background: #0d0d1c;
  color: #ffffff;
  padding: clamp(26px, 5vw, 48px) 0 calc(clamp(26px, 5vw, 44px) + var(--bottom));
}

/* Copy occupies the left of the light band, leaving the right clear for the phone, which
   is positioned over the seam below. Reserving the space here rather than letting the
   phone overlap the text is what keeps the headline readable at every width. */
.landing-copy { max-width: 34rem; }

.wordmark-slot { display: flex; color: inherit; }

.landing h1 {
  font-size: clamp(27px, 4vw, 44px);
  line-height: 1.18;
  letter-spacing: -0.028em;
  font-weight: 700;
  margin: clamp(18px, 3vw, 30px) 0 0;
  color: inherit;
}

/* ── the four claims, in the light band ── */

.claims {
  list-style: none;
  margin: clamp(24px, 4vw, 40px) 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: clamp(10px, 2vw, 22px);
}

.claims li {
  display: flex;
  flex-direction: column;
  gap: 9px;
  font-size: clamp(11.5px, 1.3vw, 13.5px);
  line-height: 1.35;
  color: #55556a;
}

.claim-glyph { color: #6a6a86; }
.claim-text { font-weight: 500; }

/* ── call to action, in the dark band ── */

/* Space above it.
   The block was written to sit directly under the headline in its own band, where the
   band's padding gave it room. Moved below the three points it inherited none, so its
   top edge landed exactly on their bottom edge — a zero-pixel gap, which reads as two
   panels colliding rather than as one section following another. */
.landing-foot .landing-cta { margin-top: clamp(34px, 5vw, 60px); }

.landing-cta { max-width: 34rem; }
.landing-cta .install-card { margin: 0; }
.landing-cta .primary { width: 100%; }

.hero-note {
  font-size: 13px;
  color: rgba(235, 235, 245, 0.5);
  margin: 14px 0 0;
}

/* The scripting requirement, stated beside the install control.
   Set quieter than the note above it but not hidden: it is a condition, not a caveat. */
.hero-requires {
  margin: 10px 0 0;
  font-size: 12.5px;
  line-height: 1.55;
  color: rgba(235, 235, 245, 0.42);
}

.hero-requires .text-link {
  font-size: 12.5px;
  color: #a99bff;
  padding: 0;
  margin-left: 2px;
}

.landing-skip { margin-top: 14px; font-size: 15px; }
.landing-bottom .landing-skip { color: #a99bff; }

/* The browser route: set apart and below the install card, so it reads as the second
   choice it is rather than an equal one. */
.landing-alt {
  margin-top: clamp(18px, 3vw, 28px);
  padding-top: clamp(16px, 2.5vw, 22px);
  border-top: 1px solid rgba(255, 255, 255, 0.09);
}

.landing-alt-lead {
  margin: 0;
  font-size: 14.5px;
  color: rgba(235, 235, 245, 0.7);
}

.landing-alt .landing-skip {
  margin-top: 8px;
  display: inline-flex;
  align-items: center;
  gap: 7px;
}

/* The arrow leans away on hover, which is the small motion that makes a link feel like it
   goes somewhere. */
.skip-arrow { flex: none; transition: transform 0.18s var(--ease); }
.landing-alt .landing-skip:hover .skip-arrow { transform: translate(2px, -2px); }

/* The warning was the dimmest text on the page and says the most consequential thing on
   it — that tickets can be lost with no way back. It is now a panel in a caution colour
   with a glyph, because a reader skimming for the button must not skim past this. Amber
   rather than red: this is a consequence to understand before choosing, not an error. */
.landing-alt-note {
  margin: 14px 0 0;
  display: flex;
  gap: 11px;
  align-items: flex-start;
  padding: 13px 15px;
  border-radius: 13px;
  border: 1px solid rgba(255, 176, 46, 0.34);
  background: rgba(255, 176, 46, 0.09);
  font-size: 13px;
  line-height: 1.5;
  color: rgba(255, 226, 176, 0.86);
  max-width: 40em;
}

.alt-warn { display: flex; flex: none; margin-top: 1px; color: #ffb02e; }
.landing-alt-note strong { color: #ffd08a; font-weight: 650; }

/* A button that reads as a link. Used where the action stays inside the app — the install
   guide — so it cannot be an anchor without inventing a URL that does not exist. */
.link-button {
  border: none;
  background: none;
  padding: 0;
  font: inherit;
  color: #a99bff;
  cursor: pointer;
}

.link-button:hover { text-decoration: underline; }

/* ── the drawn phone ── */

/* Pulled up so it crosses the seam between the bands, which is what ties them into one
   composition rather than two stacked panels.
 *
 * Above 900px it is taken out of flow and placed to the right, clear of the copy. Left in
 * flow it sat centred in the page and covered the install button — the one thing the page
 * exists to offer. Below that width there is no room beside the text, so it returns to
 * the flow and sits under the call to action instead of over it. */
/* The seam band carries no content: it is the dark half the phone straddles.
   Its height is what the phone overlaps into, so it is stated rather than left to
   collapse — an empty div is zero pixels tall and the seam would vanish with it. */
.landing-seam { min-height: clamp(90px, 13vw, 150px); }

/* On a wide screen the phone is positioned absolutely into the light band's empty right
   side, so it no longer straddles anything and the seam has nothing to hold. Left at its
   narrow height it is simply a bar of empty dark between the headline and the first
   thing a reader is meant to read. */
@media (min-width: 900px) {
  .landing-seam { min-height: 40px; }
}

.landing-device {
  display: flex;
  justify-content: center;
  /* A deeper pull than before. The call to action no longer sits beneath the phone —
     there is nothing left here to cover — so the overlap can do its job properly. */
  margin: clamp(-150px, -20vw, -90px) auto clamp(20px, 3vw, 34px);
  position: relative;
  z-index: 2;
  pointer-events: none;
}

@media (min-width: 900px) {
  .landing-device {
    position: absolute;
    top: clamp(120px, 17vw, 210px);
    left: 50%;
    /* Half the band's max width, less a margin, puts the phone in the empty right-hand
       side of the light band rather than at the centre of the page. */
    transform: translateX(clamp(60px, 14vw, 210px));
    margin: 0;
  }

  /* The bands stack in a positioning context of their own so the phone can be placed
     against the whole section rather than the viewport. */
  .landing { position: relative; }
}

.device {
  width: min(340px, 76vw);
  border-radius: 46px;
  padding: 11px;
  background: linear-gradient(160deg, #3a3a3e, #121214 46%, #29292d);
  box-shadow: 0 34px 80px rgba(8, 8, 25, 0.62), 0 2px 0 rgba(255, 255, 255, 0.09) inset;
}

.device-screen {
  border-radius: 36px;
  background: linear-gradient(180deg, #101018, #08080c);
  padding: 17px 13px 22px;
  overflow: hidden;
}

.device-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 15px; }
.device-mark { display: flex; width: 19px; height: 19px; }
.device-mark svg { width: 100%; height: 100%; }
.device-name { font-size: 16.5px; font-weight: 750; letter-spacing: -0.02em; color: #fff; }

.device-label {
  font-size: 10.5px;
  font-weight: 600;
  color: rgba(235, 235, 245, 0.45);
  margin: 0 0 6px;
}

.device-card {
  position: relative;
  border-radius: 14px;
  padding: 12px 12px 11px;
  margin-bottom: 11px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  overflow: hidden;
}

/* The same categories the app itself colours, so the drawing cannot imply a palette the
   product does not have. */
.device-card.flight { background: linear-gradient(150deg, #2f6d8f, #1d3f57); }
.device-card.rail { background: linear-gradient(150deg, #8a6a34, #4f3a1c); }
.device-card.movie { background: linear-gradient(150deg, #5b4fe8, #34246b); }
.device-card.bus { background: linear-gradient(150deg, #2f8f5e, #1b4a33); }
.device-card.event { background: linear-gradient(150deg, #b0407a, #5e1f45); }

.device-chip {
  position: absolute;
  top: 10px;
  right: 10px;
  font-size: 7px;
  font-weight: 700;
  letter-spacing: 0.07em;
  padding: 3px 6px;
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.17);
  color: rgba(255, 255, 255, 0.92);
}

.device-route {
  font-size: 19px;
  font-weight: 720;
  letter-spacing: -0.015em;
  color: #fff;
  margin-top: 13px;
}

.device-route.small { font-size: 14.5px; }
.device-arrow { opacity: 0.6; margin: 0 3px; font-weight: 500; }

.device-meta {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 8px;
  font-size: 10.5px;
  color: rgba(255, 255, 255, 0.72);
}

.device-meta em { font-style: normal; font-weight: 700; color: #fff; }

/* ── the device pair: two phones ── */

.devices { margin-bottom: clamp(30px, 5vw, 54px); }

.devices h2 {
  font-size: clamp(21px, 2.8vw, 30px);
  letter-spacing: -0.024em;
  margin: 0 0 8px;
  color: #fff;
}

.devices-lede {
  margin: 0 0 clamp(26px, 4vw, 44px);
  font-size: 15px;
  line-height: 1.55;
  color: rgba(235, 235, 245, 0.6);
  /* Narrow enough to clear the hero phone, which hangs into this band's right-hand side
     on a wide screen. At 46em the last words of the lede ran under the frame. */
  max-width: 40em;
}

.devices-row {
  display: flex;
  /* Stretch, so both frames take the height of the taller one. They previously sized to
     their own content — three cards against one pass — and stood side by side at visibly
     different heights, which read as a drawing mistake rather than as two phones. */
  align-items: stretch;
  justify-content: flex-start;
  gap: clamp(20px, 5vw, 64px);
  flex-wrap: wrap;
}

.phone {
  width: min(228px, 40vw);
  display: flex;
  flex-direction: column;
}

.phone-frame {
  position: relative;
  border-radius: 34px;
  padding: 9px;
  background: linear-gradient(160deg, #3a3a40, #16161a 48%, #2a2a30);
  box-shadow: 0 28px 60px rgba(6, 6, 20, 0.6), 0 2px 0 rgba(255, 255, 255, 0.08) inset;
  /* Fills the stretched row, so the two frames match regardless of what is on screen. */
  flex: 1;
  display: flex;
  flex-direction: column;
}

/* The two are told apart by hardware, not by interface — the app renders identically on
   both, and inventing a difference would misrepresent it. A squarer frame and a
   hole-punch camera are what read as Android at a glance. */
.phone.android .phone-frame { border-radius: 22px; }

.phone-screen {
  border-radius: 26px;
  background: linear-gradient(180deg, #101018, #08080c);
  /* Top padding is small: the status bar sits inside the screen and supplies its own
     clearance around the notch or the hole-punch. */
  padding: 7px 11px 16px;
  overflow: hidden;
  /* A ratio rather than a minimum height, so both screens are the same shape as well as
     the same height, and neither depends on how much happens to be drawn on it. */
  aspect-ratio: 9 / 18.5;
  flex: 1;
  display: flex;
  flex-direction: column;
}

.phone.android .phone-screen { border-radius: 15px; }

/* ── the status bar ──
   This is what actually distinguishes the two phones. A notch against a hole-punch is a
   difference nobody notices; the clock's position and the battery's shape are read
   instantly, without being looked for. */

.status {
  display: flex;
  align-items: center;
  height: 17px;
  margin-bottom: 8px;
  color: rgba(255, 255, 255, 0.92);
  font-size: 9.5px;
  font-weight: 640;
  letter-spacing: 0.01em;
}

.status-icons { display: flex; align-items: center; gap: 3.5px; }

/* iOS: the bar is split around the notch, so the clock is inset from the left and the
   icons from the right rather than running edge to edge. */
.ios-status { justify-content: space-between; padding: 0 3px; }
.ios-status .status-time { font-variant-numeric: tabular-nums; }

/* Android: clock hard left, icons hard right, and a slightly lighter weight. */
.android-status { justify-content: space-between; padding: 0 1px; }
.android-status .status-time { font-weight: 560; font-size: 9px; }

.phone-notch {
  position: absolute;
  top: 9px;
  left: 50%;
  transform: translateX(-50%);
  width: 42%;
  height: 17px;
  border-radius: 0 0 11px 11px;
  background: #0a0a0e;
  z-index: 2;
}

.phone-punch {
  position: absolute;
  top: 17px;
  left: 50%;
  transform: translateX(-50%);
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #05050a;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
  z-index: 2;
}

/* No caption beneath either phone.
   The drawings carry their own hardware cues — the clock's position, the shape of the
   battery, the notch against the hole-punch — and a label saying "iPhone" only tells a
   reader what they can already see. */

/* The drawer, drawn as the app draws it.
   The list is never shown without it, so a mockup that omits it is a picture of a
   different app. Pushed to the foot of the screen for the same reason as .phone-foot. */
.device-drawer {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: end;
  margin: auto -8px -12px;
  padding: 5px 4px 6px;
  border-top: 1px solid rgba(255, 255, 255, 0.09);
  background: rgba(255, 255, 255, 0.03);
}

.device-tab {
  display: grid;
  justify-items: center;
  gap: 2px;
  font-size: 7.5px;
  font-weight: 500;
  color: rgba(235, 235, 245, 0.4);
}

.device-tab.is-current { color: #8b7cff; }
.device-tab-add { color: #8b7cff; font-weight: 600; }

.device-plus {
  display: grid;
  place-items: center;
  width: 26px;
  height: 17px;
  margin-top: -9px;
  border-radius: 9px;
  background: #6c5ce7;
  color: #fff;
  font-size: 13px;
  line-height: 1;
  font-weight: 600;
}

/* The hero phone is drawn at roughly twice the size of the pair below it, so its drawer
   is too — scaled rather than reused, since a 7.5px label beside 17px card text would
   read as a different app. */
.device-drawer.large {
  margin: auto -14px -14px;
  padding: 9px 6px 11px;
}

.device-drawer.large .device-tab { font-size: 10px; gap: 3px; }
.device-drawer.large .device-plus {
  width: 34px;
  height: 22px;
  margin-top: -12px;
  border-radius: 11px;
  font-size: 17px;
}

/* The screen is a window, not a page: content that runs past the bottom is cut off by the
   frame exactly as it would be on a real phone, rather than stretching the drawing. */
.phone-screen > *:last-child { margin-bottom: 0; }

/* Nothing on a phone screen is squashed to fit.
   The screen is a flex column, so its children were shrinking when the drawing got
   narrow — cards compressed until their own text was cut through the middle, which reads
   as a rendering fault rather than as a list running past the fold. They keep their
   height; the frame clips whatever does not fit, exactly as a real screen does. */
.phone-screen > * { flex: none; }

/* Below this the frames are too narrow for everything drawn on them.
   Measured rather than guessed: at a 390px viewport the iOS screen is 284px tall against
   477px of content, so something has to go. Whole cards are removed rather than letting
   the frame cut one through its own text — a list that ends is a list, a card sliced in
   half is a bug. The screen is also allowed to be slightly taller, which recovers 60px
   of the deficit before anything is dropped at all. */
@media (max-width: 620px) {
  .phone-screen { aspect-ratio: 9 / 20; }
  .phone.ios .device-card.rail,
  .phone.ios .device-card.bus { display: none; }
  .phone.android .phone-detail div:nth-child(n+5) { display: none; }
  .phone.android .phone-foot { display: none; }
}

.phone-bar { display: flex; align-items: center; gap: 7px; margin-bottom: 13px; }
.phone-bar .device-mark { width: 16px; height: 16px; }
.phone-bar .device-name { font-size: 14.5px; }

.phone .device-card { padding: 10px 11px 9px; border-radius: 12px; margin-bottom: 9px; }
.phone .device-route { font-size: 16px; margin-top: 2px; }
.phone .device-route.small { font-size: 12.5px; }
.phone .device-meta { font-size: 9px; }

/* The Android phone shows a single pass rather than the list: it is the other half of the
   app, and what a person actually holds up at a barrier. */
.phone-pass {
  border-radius: 14px;
  padding: 13px 12px 14px;
  background: linear-gradient(150deg, #2f6d8f, #1d3f57);
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.handset-route { font-size: 16px; font-weight: 730; color: #fff; letter-spacing: -0.01em; }
.handset-sub { font-size: 9px; color: rgba(255, 255, 255, 0.7); }

/* A suggestion of a barcode, not a real one. Drawing something scannable-looking that is
   not scannable would be a small lie in a product whose whole claim is the opposite. */
.handset-code {
  display: flex;
  align-items: stretch;
  gap: 1.5px;
  height: 46px;
  margin: 9px 0 6px;
  padding: 6px 7px;
  border-radius: 6px;
  background: #fff;
}

.handset-code span { flex: 1; background: #111; border-radius: 0.5px; }
.handset-code span:nth-child(2n) { flex: 0.5; }
.handset-code span:nth-child(3n) { flex: 1.6; }
.handset-code span:nth-child(5n) { flex: 0.4; }

.handset-note { font-size: 8.5px; color: rgba(255, 255, 255, 0.6); text-align: center; }

/* The pass detail, in the app's own two-column arrangement. */
.phone-detail {
  margin: 14px 2px 0;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px 10px;
}

.phone-detail div { display: flex; flex-direction: column; gap: 2px; }

.phone-detail dt {
  font-size: 7.5px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: rgba(235, 235, 245, 0.38);
}

.phone-detail dd { margin: 0; font-size: 11.5px; font-weight: 600; color: rgba(255, 255, 255, 0.9); }

.phone-foot {
  /* Pushed to the foot of the screen, where the app puts it, rather than left floating
     halfway down with empty space beneath. */
  margin: auto 2px 0;
  padding-top: 16px;
  font-size: 8.5px;
  text-align: center;
  color: rgba(235, 235, 245, 0.3);
}

/* ── closing points ── */

.points {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 14px;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}

.points li {
  background: rgba(255, 255, 255, 0.045);
  border: 1px solid rgba(255, 255, 255, 0.09);
  border-radius: var(--radius);
  padding: 18px 18px 16px;
  font-size: 13.5px;
  line-height: 1.5;
  color: rgba(235, 235, 245, 0.62);
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.point-glyph {
  display: flex;
  width: 38px;
  height: 38px;
  align-items: center;
  justify-content: center;
  border-radius: 11px;
  margin-bottom: 7px;
  color: #a99bff;
  background: rgba(169, 155, 255, 0.14);
}

.points strong { color: #fff; font-weight: 640; font-size: 15px; }

/* Below this the four claims cannot hold a row without wrapping to two words a line. */
@media (max-width: 620px) {
  .claims { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.install-steps {
  text-align: left;
  background: var(--surface);
  backdrop-filter: var(--blur);
  -webkit-backdrop-filter: var(--blur);
  border-radius: var(--radius);
  padding: 18px 20px;
}

.install-lead { font-weight: 600; margin-bottom: 10px; }
.install-body { font-size: 14.5px; color: var(--label-2); line-height: 1.5; margin-bottom: 12px; }
.install-body.small { font-size: 13.5px; margin: 14px 0 0; }
.install-actions { display: flex; flex-direction: column; gap: 9px; }
.install-steps ol { margin: 0; padding-left: 20px; color: var(--label-2); font-size: 15.5px; }
.install-steps li { margin: 7px 0; }
.install-steps strong { color: var(--label); font-weight: 600; }

.ghost-button {
  border: 1px solid var(--separator);
  border-radius: 12px;
  background: none;
  color: var(--accent);
  font: inherit;
  font-size: 16px;
  padding: 12px;
  cursor: pointer;
  transition: background 0.2s var(--ease), transform 0.14s var(--ease);
}

.ghost-button:active { transform: scale(0.98); }

.inline-glyph { vertical-align: -2px; color: var(--accent); }

.qr-handoff { margin-top: 26px; }
#qr-canvas { border-radius: 10px; background: #fff; padding: 8px; box-shadow: var(--shadow); }
.qr-note { font-size: 13px; color: var(--label-2); margin-top: 10px; }

/* ───────────────────────── home ───────────────────────── */

.home-body { padding: 16px 16px calc(96px + var(--bottom)); }

.section-label {
  font-size: 13px;
  font-weight: 600;
  color: var(--label-2);
  letter-spacing: 0.01em;
  margin: 4px 4px 10px;
}

.list-block, .past-block { margin-top: 26px; }

/* The ticket card. Its colour comes from the operator's own brand where we could find
   one, which is what makes a wallet feel like it holds real tickets. */

.card {
  display: block;
  width: 100%;
  text-align: left;
  border: 0;
  border-radius: var(--radius-lg);
  padding: 16px 18px;
  margin-bottom: 12px;
  /*
   * A pass is lit, not painted.
   *
   * A single flat fill is much of the difference between this and a Wallet card: a real
   * one is printed stock catching light, so the colour lifts toward the top-left corner
   * and deepens away from it. `deep` and `lift` were already being computed for the pass
   * artwork and simply never reached the card, which is why every one of these read as a
   * coloured rectangle.
   */
  background:
    radial-gradient(125% 100% at 14% -8%,
      color-mix(in srgb, var(--card-lift, transparent) 40%, transparent) 0%, transparent 62%),
    linear-gradient(158deg, var(--card-bg, #1d3557) 0%, var(--card-deep, #16283f) 100%);
  color: var(--card-fg, #fff);
  font: inherit;
  cursor: pointer;
  /* Shadow, sheen and press feedback are defined above; repeating them here overrode
     them with a flatter version. */
}

/*
 * The press. Written as a scale factor rather than a whole transform, so it can be
 * composed with the swipe's translation instead of replacing it.
 *
 * `.card:active { transform: scale(...) }` is more specific than `.swipe > .card`, and a
 * card is active for the entire duration of a drag — so the press silently won and the
 * card stayed put while the gesture ran to completion underneath it. A swipe deleted a
 * pass having never moved a pixel.
 */
.card {
  --press: 1;
  transform: translateX(var(--swipe, 0px)) scale(var(--press));
}

/*
 * The press eases; the drag must not.
 *
 * A transition on `transform` would apply to the swipe too, so the card would lag a
 * frame or two behind the finger and feel loose. During a drag the transition is dropped
 * and the card tracks the pointer exactly; `.settling` puts it back for the journey home.
 */
.card { transition: transform 0.34s cubic-bezier(0.2, 0.9, 0.3, 1), filter 0.2s ease; }
.swipe.dragging > .card { transition: filter 0.2s ease; }

.card:active { --press: 0.985; }

/* Not while swiping: the card is following a finger, not being pressed. */
.swipe.dragging > .card:active { --press: 1; }

.card.large { padding: 20px; }
.card.dim { opacity: 0.55; }

.card-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 14px; }

.card-provider {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--card-label, rgba(255, 255, 255, 0.7));
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.card-chip {
  font-size: 10px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  padding: 3px 8px;
  border-radius: 980px;
  background: rgba(255, 255, 255, 0.16);
  white-space: nowrap;
}

.card-route { display: flex; align-items: center; gap: 12px; }
.card-route .place { font-size: clamp(21px, 5.8vw, 26px); font-weight: 600; letter-spacing: -0.02em; }
.card.large .card-route .place:not(.single) { font-size: clamp(26px, 7.4vw, 32px); }

/*
 * A title is not a three-letter code, and must not be set like one.
 *
 * This needs the `.card.large` prefix rather than reading as a standalone rule: the
 * large-card size above is four classes deep, so a plainer selector here lost to it on
 * specificity and every film title on the featured card was set at 32px regardless.
 */
.card-route .place.single,
.card.large .card-route .place.single {
  font-size: clamp(15px, 4.4vw, 21px);
  line-height: 1.25;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
}
.card.large .card-route .place.single { font-size: clamp(17px, 5vw, 23px); }
.card-route .arrow { opacity: 0.7; display: flex; }
.plain-arrow { font-size: 18px; }

.card-foot {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 12px;
  margin-top: 12px;
}

.card-detail { font-size: 13.5px; color: var(--card-label, rgba(255, 255, 255, 0.72)); }
.card-seat { font-size: 15px; font-weight: 600; white-space: nowrap; }

.past-block summary {
  list-style: none;
  cursor: pointer;
  font-size: 13px;
  font-weight: 600;
  color: var(--label-2);
  padding: 8px 4px;
  display: flex;
  align-items: center;
  gap: 8px;
}

.past-block summary::-webkit-details-marker { display: none; }

.count {
  background: var(--fill);
  border-radius: 980px;
  padding: 1px 8px;
  font-size: 12px;
}

.empty { text-align: center; padding: 70px 20px; }
.empty-mark { color: var(--label-3); margin-bottom: 18px; }
.empty h2 { font-size: 21px; margin-bottom: 6px; }
.empty .muted { margin-bottom: 22px; font-size: 15px; }

/* The "+" shown inline in the empty state, matching the real button in the bar. */
.inline-plus {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px; height: 20px;
  margin: 0 1px;
  vertical-align: -4px;
  border-radius: 6px;
  background: var(--fill);
  color: var(--accent);
  font-weight: 600;
  line-height: 1;
}

.caution {
  margin-top: 22px;
  padding: 13px 16px;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--orange) 13%, var(--surface-solid));
  border: 1px solid color-mix(in srgb, var(--orange) 30%, transparent);
  font-size: 14px;
}

/* ───────────────────────── the drawer ─────────────────────────
   The app's own controls, along the bottom edge.

   It replaces a floating button that sat in the corner holding the only way to add a
   ticket. A phone is held at the bottom, so the one action the app exists for was as far
   from the thumb as the screen allows, with no label to say what it did.

   Add is raised and coloured rather than being a third equal tab, because it is not a
   destination: it is the action. Tickets and Help are places you go; Add is a thing you
   do, and drawing all three identically would say otherwise. */
.drawer {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 30;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: end;
  /*
   * Painted past the edge, positioned by the inset.
   *
   * Measured on an iPhone in standalone: window 402×812 against a screen of 402×874, with
   * safe-area-inset-bottom reporting 34px. The bar sat correctly at the page's bottom —
   * 812, exactly the window height — and there was still a black strip beneath it,
   * because the page is 62px shorter than the screen and that strip is not part of the
   * document at all. Nothing positioned inside it can reach there, which is why padding,
   * an opaque background and a backstop element all failed in turn.
   *
   * So the box is pushed below the viewport and its padding grows by the same amount.
   * The content lands in exactly the position it did before — the arithmetic cancels —
   * while the painted surface continues past the edge into whatever strip the device
   * leaves.
   *
   * `OVERHANG` is not a measurement of any device. It is simply more than any of them
   * leave, which is the point: a number tuned to one phone would be wrong on the next.
   * Where there is no strip, as on every desktop browser, it sits off-screen and is
   * never seen.
   */
  --overhang: 120px;
  bottom: calc(var(--overhang) * -1);
  padding: 7px 6px calc(var(--overhang) + max(9px, var(--bottom)));
  background: var(--surface-solid);
  border-top: 1px solid var(--separator);

  /* Its own layer, for the same reason as the top bar: a translucent strip that blurs
     unevenly while content scrolls beneath it looks broken. */
  transform: translateZ(0);
  isolation: isolate;
}

/* The backstop that used to sit here is gone. It was an element positioned at the
   drawer's own bottom edge, and so lived inside the same viewport the strip is outside
   of — it could never have covered it. The overhang above does the work instead. */

/* ── with scripting disabled ──
   The only thing on the page that can speak, so it has to carry the whole message
   unaided. Deliberately plain: no gradients, no drawn phone, nothing that depends on a
   script having arranged it first. */
.noscript-shell {
  max-width: 32rem;
  margin: 0 auto;
  padding: clamp(48px, 12vw, 96px) 24px;
  text-align: left;
}

.noscript-shell h1 { font-size: clamp(26px, 6vw, 34px); margin-bottom: 18px; }
.noscript-shell p { margin-bottom: 14px; color: var(--label-2); line-height: 1.6; }
.noscript-shell strong { color: var(--label); font-weight: 600; }

.noscript-aside {
  margin-top: 24px;
  padding-top: 18px;
  border-top: 1px solid var(--separator);
  font-size: 15px;
}

/* ── the readiness check ──
   Tested capabilities, not a list of warnings. A line that says "make sure storage is
   enabled" leaves the reader to work out whether it is; a green tick answers it. */
.check-list { list-style: none; margin: 0 0 18px; padding: 0; }

.check {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 11px;
  align-items: start;
  padding: 11px 0;
  border-bottom: 1px solid var(--separator);
}

.check:last-child { border-bottom: 0; }
.check strong { display: block; font-weight: 600; font-size: 15.5px; }
.check em { display: block; font-style: normal; font-size: 14px; color: var(--label-2); margin-top: 2px; }

.check-mark {
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  font-size: 13px;
  font-weight: 700;
  margin-top: 1px;
}

.check.ok .check-mark { background: color-mix(in srgb, var(--green) 20%, transparent); color: var(--green); }
.check.bad .check-mark { background: color-mix(in srgb, var(--orange) 22%, transparent); color: var(--orange); }

/* ── feedback ── */

.feedback-lead { margin-bottom: 12px; }

.feedback-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

/* Anchors, styled as buttons.
   They are links rather than buttons because a popup blocker refuses a scripted
   window.open — silently, on Safari — while a plain anchor is navigation the browser
   never questions. */
.feedback-button {
  display: grid;
  justify-items: center;
  gap: 7px;
  padding: 16px 10px;
  border: 1px solid var(--separator);
  border-radius: var(--radius-md, 14px);
  background: var(--surface-solid);
  color: var(--accent);
  font-size: 13.5px;
  font-weight: 600;
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  transition: transform 0.12s var(--ease), background 0.18s var(--ease);
}

.feedback-button:active { transform: scale(0.97); }
.feedback-button:hover { background: var(--fill); }

.drawer-item {
  display: grid;
  justify-items: center;
  gap: 3px;
  padding: 5px 4px 4px;
  border: 0;
  background: none;
  color: var(--label-2);
  font: inherit;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: -0.005em;
  cursor: pointer;
  border-radius: 12px;
  transition: color 0.18s var(--ease), transform 0.12s var(--ease);
}

.drawer-item:active { transform: scale(0.94); }
.drawer-item.is-current { color: var(--accent); }

/* The raised action.
   Lifted out of the strip rather than merely tinted, so the difference between a place
   and an action survives being glanced at. */
.drawer-plus {
  display: grid;
  place-items: center;
  /* Wide rather than tall. A near-circular pill sat proud of the row and read as the old
     floating button that had merely moved; a lozenge the width of a label reads as part
     of the bar. */
  width: 52px;
  height: 30px;
  margin-top: -11px;
  border-radius: 15px;
  background: var(--accent);
  color: #fff;
  box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 38%, transparent);
  transition: transform 0.12s var(--ease);
}

.drawer-add { color: var(--accent); font-weight: 600; }
.drawer-add:active .drawer-plus { transform: scale(0.94); }

/* Room for the drawer at the end of the list, so the last ticket is never trapped
   beneath it. Measured from the drawer's own height rather than guessed. */
.home .home-body { padding-bottom: calc(78px + var(--bottom)); }

/* ───────────────────────── the ticket ───────────────────────── */

.pass-scroll { padding: 16px 16px calc(40px + var(--bottom)); }

.pass-card {
  border-radius: var(--radius-lg);
  padding: 22px 20px;
  /* Same lighting as the card it was opened from — it is the same object, larger. */
  background:
    radial-gradient(125% 100% at 14% -8%,
      color-mix(in srgb, var(--card-lift, transparent) 40%, transparent) 0%, transparent 62%),
    linear-gradient(158deg, var(--card-bg, #1d3557) 0%, var(--card-deep, #16283f) 100%);
  color: var(--card-fg, #fff);
  box-shadow: var(--shadow);
}

.pass-route { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.endpoint { min-width: 0; }
.endpoint.right { text-align: right; }
/*
 * The route, sized to the card rather than the screen.
 *
 * 38px was fixed, which is right on a phone and badly out of proportion in a 620px
 * column on a desktop — the codes dwarfed everything around them and the hierarchy read
 * as broken. Scaling with the viewport is no better, since the card does not grow with
 * it. Clamped values keep the same relationship at both ends.
 */
.endpoint .code {
  display: block;
  font-size: clamp(26px, 7.4vw, 34px);
  font-weight: 600;
  letter-spacing: -0.025em;
  line-height: 1.05;
}

/*
 * A single heading — a film, a hotel — rather than an airport code.
 *
 * Sized to fit rather than fixed: "IXE" and "K.G.F. Chapter 2 (2D) (Kannada)" cannot
 * share a size, and the long one was overflowing the card. It wraps to two lines and
 * then truncates, because a third line pushes the fields off the pass.
 */
.endpoint .code.single {
  font-size: clamp(17px, 5.6vw, 26px);
  line-height: 1.18;
  letter-spacing: -0.01em;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
}

.place-name {
  display: block;
  font-size: 12px;
  color: var(--card-label, rgba(255, 255, 255, 0.7));
  margin-top: 3px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pass-glyph { flex: none; opacity: 0.72; }
/* The transit mark inherits the pass's own foreground, or it renders as a grey block. */
.pass-glyph svg, .card-route .arrow svg { fill: currentColor; }

/* A grid cell will not shrink below the widest unbreakable word inside it unless it is
   told it may. "SHASTRY/RAVISHANKARA" has no space to break at, so the passenger cell
   grew past its track and printed straight through the booking reference beside it —
   two values overlapping, neither legible. `min-width: 0` lets the track hold its share,
   and the wrap rules give the word somewhere to break. */
.pass-field { min-width: 0; overflow-wrap: anywhere; word-break: break-word; }

.pass-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
  gap: 16px 14px;
  margin-top: 24px;
}

.pass-field .k {
  display: block;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--card-label, rgba(255, 255, 255, 0.7));
}

.pass-field .v { display: block; font-size: 17px; font-weight: 600; margin-top: 2px; }
.pass-field.unsure .v::after { content: ' ?'; opacity: 0.6; }

.show-code {
  width: 100%;
  margin-top: 24px;
  padding: 13px;
  border: 0;
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.16);
  color: inherit;
  font: inherit;
  font-size: 15.5px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  cursor: pointer;
  transition: background 0.2s var(--ease), transform 0.12s var(--ease);
}

.show-code:hover { background: rgba(255, 255, 255, 0.24); }
.show-code:active { transform: scale(0.98); }

.no-code {
  margin-top: 20px;
  font-size: 13.5px;
  color: var(--card-label, rgba(255, 255, 255, 0.72));
}

.pass-section { margin-top: 26px; }
.pass-section h2 { font-size: 13px; font-weight: 600; color: var(--label-2); margin-bottom: 10px; padding: 0 4px; }

.section-body {
  background: var(--surface-solid);
  border-radius: var(--radius);
  padding: 14px 16px;
  font-size: 15px;
  line-height: 1.45;
}

.section-body + .section-body { margin-top: 8px; }

.caution-section .section-body {
  background: color-mix(in srgb, var(--orange) 12%, var(--surface-solid));
  border: 1px solid color-mix(in srgb, var(--orange) 28%, transparent);
}

.detail-list {
  margin: 0;
  background: var(--surface-solid);
  border-radius: var(--radius);
  overflow: hidden;
}

.detail-list > div {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 12px 16px;
  border-top: 1px solid var(--separator);
}

.detail-list > div:first-child { border-top: 0; }
.detail-list dt { font-size: 14px; color: var(--label-2); flex: none; }
.detail-list dd { margin: 0; font-size: 14.5px; text-align: right; word-break: break-word; }
.detail-list.subtle dd { color: var(--label-2); font-size: 13.5px; }

.pass-disclaimer {
  margin-top: 26px;
  padding: 0 6px;
  font-size: 12.5px;
  color: var(--label-3);
  line-height: 1.5;
  text-align: center;
}

/* ───────────────────────── scan ─────────────────────────
 * White regardless of theme. A web page cannot raise screen brightness the way a native
 * wallet can, so the largest possible white field is the only compensation available.
 * Nothing is styled here — the barcode must be exactly what a scanner expects.
 *
 * The close button is hidden until the screen is tapped. This is the one moment in the
 * app that should carry no distraction, and a button permanently under the thumb is a
 * button that gets pressed while the phone is being handed over. */

.scan {
  position: fixed;
  inset: 0;
  max-width: none;
  background: #ffffff;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  /* Zoomed past the screen edge, the code must be reachable. */
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}

.scan-close {
  position: absolute;
  top: calc(var(--top) + 12px);
  right: 14px;
  width: 42px; height: 42px;
  border: 0; border-radius: 50%;
  background: rgba(0, 0, 0, 0.07);
  color: #1c1c1e;
  display: grid; place-items: center;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transform: scale(0.9);
  transition: opacity 0.28s var(--ease), transform 0.28s var(--ease);
}

.scan.controls .scan-close { opacity: 1; pointer-events: auto; transform: none; }

/*
 * Always visible where there is a mouse.
 *
 * Hiding the way out until the screen is tapped suits a phone, where the gesture is
 * natural and the clutter is worth avoiding. With a mouse there is no tap, so the button
 * simply never appeared and the only exit was the keyboard — the code opened and there
 * was no way back.
 */
@media (hover: hover) and (pointer: fine) {
  .scan-close { opacity: 1; pointer-events: auto; transform: none; }
}

/* Keyboard focus must always reveal it, whatever the pointer. */
.scan-close:focus-visible { opacity: 1; pointer-events: auto; transform: none; }

.scan-hint {
  position: absolute;
  bottom: calc(var(--bottom) + 26px);
  left: 0; right: 0;
  text-align: center;
  font-size: 12px;
  color: #aeaeb2;
  opacity: 1;
  transition: opacity 0.4s var(--ease);
}

.scan.controls .scan-hint { opacity: 0; }

.scan-inner { text-align: center; padding: 20px; width: 100%; }

#scan-canvas {
  /*
   * As large as the screen allows.
   *
   * A web page cannot raise screen brightness the way a native wallet can, so physical
   * size is the only thing that helps a scanner — and a barcode too small to read makes
   * this screen pointless.
   *
   * A wide code is rotated on its own canvas rather than with a transform here, so its
   * layout box is the size it actually occupies and nothing overlaps it.
   */
  max-width: min(96vw, 900px);
  max-height: 74vh;
  width: auto;
  height: auto;
  /* Nearest-neighbour: any smoothing softens the module edges a scanner looks for. */
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  transition: transform 0.22s var(--ease);
  transform-origin: center;
}

/* The screen owns the gesture.
   Without this the browser claims a pinch for its own page zoom and the code never
   receives it — the app appears to ignore the gesture entirely. */
.screen.scan { touch-action: none; }

.scan-reference {
  margin-top: 20px;
  font-size: 21px;
  font-weight: 600;
  letter-spacing: 0.06em;
  color: #1c1c1e;
  font-variant-numeric: tabular-nums;
}

.scan-note { margin-top: 8px; font-size: 12.5px; color: #6e6e73; }

/* ───────────────────────── add ───────────────────────── */

.add-body { padding: 30px 20px calc(40px + var(--bottom)); }

.dropzone {
  border: 1.5px dashed var(--label-3);
  border-radius: var(--radius-lg);
  background: var(--surface);
  backdrop-filter: var(--blur);
  -webkit-backdrop-filter: var(--blur);
  padding: 46px 22px;
  text-align: center;
  cursor: pointer;
  color: var(--accent);
  transition: border-color 0.22s var(--ease), background 0.22s var(--ease), transform 0.22s var(--ease);
}

.dropzone:hover { border-color: var(--accent); }
.dropzone.over { border-color: var(--accent); border-style: solid; transform: scale(1.01); }
.dz-title { font-size: 18px; font-weight: 600; color: var(--label); margin-top: 12px; }
.dz-hint { font-size: 14px; color: var(--label-2); margin-top: 5px; }

.paste-hint { text-align: center; font-size: 13.5px; color: var(--label-2); margin-top: 16px; }

kbd {
  font: inherit; font-size: 12px;
  padding: 2px 6px;
  border-radius: 5px;
  background: var(--fill);
  border: 1px solid var(--separator);
}

/* ───────────────────────── working ───────────────────────── */

.working-inner { text-align: center; padding: 90px 24px; }

.spinner {
  width: 28px; height: 28px;
  margin: 0 auto 20px;
  border: 2.5px solid var(--fill);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 0.85s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

.steps {
  list-style: none;
  margin: 26px auto 0;
  padding: 0;
  max-width: 340px;
  text-align: left;
  display: grid;
  gap: 1px;
  background: var(--separator);
  border-radius: var(--radius);
  overflow: hidden;
}

.steps li {
  background: var(--surface-solid);
  padding: 11px 16px;
  font-size: 14.5px;
  color: var(--label-3);
  display: flex; align-items: center; gap: 10px;
  transition: color 0.3s var(--ease);
}

.steps li::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
.steps li.active { color: var(--accent); }
.steps li.done { color: var(--label-2); }
.steps li.done::before { background: var(--green); box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 20%, transparent); }

/* ───────────────────────── review ───────────────────────── */

.review-scroll { padding: 16px 16px calc(40px + var(--bottom)); }

.notice {
  background: color-mix(in srgb, var(--orange) 13%, var(--surface-solid));
  border: 1px solid color-mix(in srgb, var(--orange) 30%, transparent);
  border-radius: var(--radius);
  padding: 13px 16px;
  font-size: 14.5px;
  line-height: 1.45;
  margin-bottom: 18px;
}

.notice p + p { margin-top: 7px; }

.review-group { margin-bottom: 24px; }
.review-group h2 { font-size: 13px; font-weight: 600; color: var(--label-2); padding: 0 4px; margin-bottom: 8px; }
.group-note { font-size: 13px; color: var(--label-2); padding: 0 4px; margin-bottom: 12px; }

.review-group.urgent {
  background: color-mix(in srgb, var(--orange) 8%, var(--surface-solid));
  border: 1px solid color-mix(in srgb, var(--orange) 26%, transparent);
  border-radius: var(--radius);
  padding: 16px;
}

.all-clear {
  display: flex; align-items: center; gap: 10px;
  background: color-mix(in srgb, var(--green) 12%, var(--surface-solid));
  border: 1px solid color-mix(in srgb, var(--green) 28%, transparent);
  border-radius: var(--radius);
  padding: 13px 16px;
  font-size: 15px;
  color: var(--green);
  margin-bottom: 22px;
}

.field { padding: 12px 0; border-top: 1px solid var(--separator); }
.field:first-of-type { border-top: 0; }
.field-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
.field label { font-size: 13px; font-weight: 600; color: var(--label-2); }

.field input {
  font: inherit;
  /* 16px minimum: anything smaller makes iOS Safari zoom on focus. */
  font-size: 16px;
  width: 100%;
  padding: 10px 13px;
  border-radius: 10px;
  border: 1px solid var(--separator);
  background: var(--bg);
  color: var(--label);
  transition: border-color 0.18s var(--ease), box-shadow 0.18s var(--ease);
}

.field input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3.5px color-mix(in srgb, var(--accent) 18%, transparent);
}

.field.critical input { border-left: 3px solid var(--orange); }
.field .issue { font-size: 13px; color: var(--orange); margin-top: 6px; }
.field .note { font-size: 12.5px; color: var(--label-2); margin-top: 6px; }

.badge {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.04em;
  padding: 3px 8px;
  border-radius: 980px;
  background: var(--fill);
  color: var(--label-2);
  white-space: nowrap;
}

.badge.good { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
.badge.warn { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); }
.badge.edited { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }

.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 9px; }

/* Passenger rows: editable, removable, and addable — a family booking is where
   extraction is least reliable and where a quiet omission costs the most. */

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

.passenger-row input {
  flex: 1;
  font: inherit;
  /* 16px minimum: anything smaller makes iOS Safari zoom on focus. */
  font-size: 16px;
  padding: 10px 13px;
  border-radius: 10px;
  border: 1px solid var(--separator);
  background: var(--bg);
  color: var(--label);
}

.passenger-row input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3.5px color-mix(in srgb, var(--accent) 18%, transparent);
}

.row-remove {
  flex: none;
  width: 34px; height: 34px;
  display: grid; place-items: center;
  border: 0; border-radius: 50%;
  background: none;
  color: var(--label-3);
  cursor: pointer;
  transition: background 0.2s var(--ease), color 0.2s var(--ease);
}

.row-remove:hover { background: var(--fill); color: var(--red); }

.passenger-list {
  margin: 0;
  padding: 0 0 0 22px;
  background: var(--surface-solid);
  border-radius: var(--radius);
  padding: 14px 16px 14px 36px;
}

.passenger-list li { font-size: 15px; padding: 3px 0; }

.chip {
  font: inherit; font-size: 13.5px;
  border: 1px solid var(--separator);
  background: var(--surface-solid);
  color: var(--accent);
  border-radius: 980px;
  padding: 6px 14px;
  cursor: pointer;
  transition: background 0.18s var(--ease), transform 0.12s var(--ease);
}

.chip:active { transform: scale(0.96); }
.chip.good { color: var(--green); }

/* ───────────────────────── sheets: help and settings ───────────────────────── */

/* The pager is now in the flow, so the body no longer reserves space beneath it. */
.help-body, .settings-body { padding: 22px 20px 28px; }

.help-lead { font-size: 17px; margin-bottom: 18px; }
.help-body h3 { font-size: 15.5px; margin: 22px 0 7px; }
.help-body p { font-size: 15px; color: var(--label-2); margin-bottom: 10px; line-height: 1.5; }
.help-body strong { color: var(--label); font-weight: 600; }

.help-steps { margin: 0 0 16px; padding-left: 22px; }
.help-steps li { font-size: 15px; color: var(--label-2); margin: 9px 0; line-height: 1.5; }

.help-note {
  background: var(--surface-solid);
  border-radius: var(--radius);
  padding: 14px 16px;
  font-size: 14px;
  color: var(--label-2);
  line-height: 1.5;
  margin: 16px 0;
}

.help-warning {
  background: color-mix(in srgb, var(--red) 11%, var(--surface-solid));
  border: 1px solid color-mix(in srgb, var(--red) 32%, transparent);
  border-radius: var(--radius);
  padding: 16px;
  font-size: 14.5px;
  line-height: 1.5;
  color: var(--label-2);
  margin-bottom: 18px;
}

.help-warning strong { display: block; color: var(--label); margin-bottom: 5px; }

.pager {
  /* Part of the sheet's column rather than floating over it. Fixed positioning left it
     detached from the content, which is how it ended up sitting on top of the text. */
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 18px;
  padding: 12px 20px calc(12px + var(--bottom));
  background: var(--surface);
  backdrop-filter: var(--blur);
  -webkit-backdrop-filter: var(--blur);
  border-top: 1px solid var(--separator);
}

.dots { display: flex; gap: 8px; }

.dot {
  width: 8px; height: 8px;
  border: 0; border-radius: 50%;
  background: var(--label-3);
  cursor: pointer;
  padding: 0;
  transition: background 0.25s var(--ease), transform 0.25s var(--ease);
}

.dot.on { background: var(--accent); transform: scale(1.25); }

/* settings */

.group { margin-bottom: 26px; }
/*
 * Settings headings.
 *
 * Uppercased and tracked out, the way a grouped list is set on iOS, with a small icon
 * ahead of the text. The icon is a landmark for the eye when scrolling past several
 * sections — which is why it sits on the heading and not on every row beneath it.
 */
.group h2 {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--label-2);
  padding: 0 4px;
  margin-bottom: 8px;
}

.group-icon { flex: none; opacity: 0.85; }
.group-icon.danger { color: var(--red); opacity: 1; }

.group-note { font-size: 13px; color: var(--label-2); padding: 0 4px; margin-top: 9px; line-height: 1.45; }
.group-note.warn-note { color: var(--orange); }
.warn-note { color: var(--orange); }

.options-list {
  background: var(--surface-solid);
  border-radius: var(--radius);
  overflow: hidden;
}

.option {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  border: 0;
  border-top: 1px solid var(--separator);
  background: none;
  color: var(--label);
  font: inherit;
  padding: 13px 16px;
  cursor: pointer;
  text-align: left;
}

.option:first-child { border-top: 0; }
.option-text { display: block; min-width: 0; }
.option-text strong { display: block; font-size: 16px; font-weight: 500; }
.option-text em { font-style: normal; font-size: 13px; color: var(--label-2); }

.option-tick { color: var(--accent); opacity: 0; flex: none; transition: opacity 0.2s var(--ease); }
.option.on .option-tick { opacity: 1; }

.switch-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  background: var(--surface-solid);
  border-radius: var(--radius);
  padding: 14px 16px;
  cursor: pointer;
}

.switch-row input { position: absolute; opacity: 0; pointer-events: none; }

.track {
  flex: none;
  width: 51px; height: 31px;
  border-radius: 980px;
  background: var(--fill);
  position: relative;
  transition: background 0.28s var(--ease);
}

.thumb {
  position: absolute;
  top: 2px; left: 2px;
  width: 27px; height: 27px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.22);
  transition: transform 0.28s var(--ease);
}

.switch-row input:checked ~ .track { background: var(--green); }
.switch-row input:checked ~ .track .thumb { transform: translateX(20px); }
.switch-row input:disabled ~ .track { opacity: 0.4; }

.info-card {
  background: var(--surface-solid);
  border-radius: var(--radius);
  padding: 14px 16px;
  font-size: 14px;
  color: var(--label-2);
  line-height: 1.5;
}

/*
 * A label and its value on one line, for facts rather than prose.
 *
 * The build number was a sentence — "You are running 21 (7ba5bfd), from 11 Aug 2026" —
 * which has to be read to be understood. Anyone opening this section wants to find a
 * value and quote it, so it is set as a value.
 */
.fact-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 16px;
  padding: 3px 0;
}

.fact-label { font-size: 13px; color: var(--label-2); }

/* The note under a fact list, separated from it the way the update line is. */
.storage-note {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--separator);
  font-size: 13px;
  line-height: 1.45;
}

.fact-value {
  font-size: 14px;
  font-weight: 600;
  color: var(--label);
  font-variant-numeric: tabular-nums;
  text-align: right;
  /* The display readout is a long string of measurements, and a fact row is a two-column
     grid — without this it forces the row wider than the sheet. */
  min-width: 0;
  overflow-wrap: anywhere;
}

/* The update line. Its icon carries the state; the colour confirms it. */
.update-state {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--separator);
  font-size: 13.5px;
  font-weight: 500;
}

.update-state .update-icon { display: flex; flex: none; }
.update-state.current { color: var(--green); }
.update-state.ready { color: var(--accent); }
.update-state.checking { color: var(--label-2); }
.update-state.checking .update-icon { animation: pulse 1.4s ease-in-out infinite; }

@keyframes pulse { 50% { opacity: 0.35; } }

@media (prefers-reduced-motion: reduce) {
  .update-state.checking .update-icon { animation: none; }
}

/*
 * Picking what kind of ticket this is.
 *
 * A grid of glyphs rather than a dropdown: the artwork the choice selects is the thing
 * being chosen, so showing it is both the clearest label and an honest preview.
 */
.kind-picker {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
}

.kind-option {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 7px;
  padding: 14px 6px;
  background: var(--surface-solid);
  border: 1.5px solid transparent;
  border-radius: var(--radius);
  color: var(--label-2);
  font-size: 12.5px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.12s ease;
}

.kind-option .kind-glyph { display: flex; opacity: 0.7; transition: opacity 0.18s ease; }
.kind-option:active { transform: scale(0.96); }

.kind-option.on {
  border-color: var(--accent);
  color: var(--label);
  background: color-mix(in srgb, var(--accent) 12%, var(--surface-solid));
}

.kind-option.on .kind-glyph { opacity: 1; color: var(--accent); }


.info-card p + p { margin-top: 7px; }

.segmented {
  display: flex;
  background: var(--fill);
  border-radius: 10px;
  padding: 2px;
}

.segmented button {
  flex: 1;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--label);
  font: inherit;
  font-size: 14.5px;
  padding: 8px;
  cursor: pointer;
  transition: background 0.25s var(--ease);
}

.segmented button[aria-pressed='true'] {
  background: var(--surface-solid);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
  font-weight: 600;
}

.row {
  width: 100%;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  border: 0;
  border-top: 1px solid var(--separator);
  background: var(--surface-solid);
  color: var(--label);
  font: inherit;
  font-size: 16px;
  padding: 14px 16px;
  cursor: pointer;
  text-align: left;
}

.row:first-of-type { border-top: 0; border-radius: var(--radius) var(--radius) 0 0; }
.row:last-of-type { border-radius: 0 0 var(--radius) var(--radius); }
.row:only-of-type { border-radius: var(--radius); }
.row-note { color: var(--label-2); font-size: 14px; }
.row.danger { color: var(--red); }

.colophon { text-align: center; padding: 10px 10px 24px; color: var(--label-3); }
.colophon svg { opacity: 0.5; margin-bottom: 10px; }

/* Separated, or the two lines read as one run-on sentence. */
.colophon p { font-size: 12.5px; line-height: 1.5; margin: 0; }
.colophon p + p { margin-top: 12px; }

.colophon .credit {
  padding-top: 12px;
  border-top: 1px solid var(--separator);
  display: inline-block;
}

/* A row of information is not a control; keep it clear of the button below it. */
.group .info-card + .row,
.group .info-card + button { margin-top: 12px; }

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

.primary {
  border: 0;
  border-radius: 12px;
  background: var(--accent);
  color: #fff;
  font: inherit;
  font-size: 17px;
  font-weight: 500;
  padding: 13px 26px;
  cursor: pointer;
  transition: transform 0.14s var(--ease), background 0.2s var(--ease);
}

.primary.big { width: 100%; padding: 15px; font-size: 17px; font-weight: 600; }
.primary:active { transform: scale(0.975); }

/* ───────────────────────── toast ───────────────────────── */

.toast {
  position: fixed;
  left: 50%;
  bottom: calc(28px + var(--bottom));
  transform: translateX(-50%);
  z-index: 200;
  max-width: min(90vw, 420px);
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 13px 18px;
  border-radius: 14px;
  background: rgba(28, 28, 30, 0.94);
  color: #fff;
  backdrop-filter: var(--blur);
  -webkit-backdrop-filter: var(--blur);
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
  font-size: 14.5px;
  animation: toast-in 0.32s var(--ease) both;
}

.toast.bad { background: rgba(200, 40, 32, 0.96); }
/*
 * The menu behind "⋯" on a ticket.
 *
 * Sits above everything, respects the home indicator, and puts the destructive option
 * last and clearly marked. It replaced a bare confirm() that deleted immediately.
 */
.action-sheet {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  align-items: flex-end;
  justify-content: center;
}

.action-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.4);
  animation: fade-in 0.18s ease;
}

.action-panel {
  position: relative;
  width: min(100% - 20px, 460px);
  margin-bottom: calc(10px + var(--bottom));
  display: flex;
  flex-direction: column;
  gap: 1px;
  border-radius: 15px;
  overflow: hidden;
  background: var(--separator);
  animation: sheet-up 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}

/*
 * The original page, as a picture.
 *
 * Taller than the other sheets, because the whole point is to read small print. The image
 * scrolls inside a bounded panel rather than sizing the sheet to the page — a portrait A4
 * scaled to fit a phone's width is unreadable, and scaled to be readable is taller than
 * any screen.
 */
.original-panel { width: min(100% - 20px, 560px); }

.original-scroll {
  max-height: min(70vh, 640px);
  overflow: auto;
  background: var(--surface-solid);
  -webkit-overflow-scrolling: touch;
}

.original-image {
  display: block;
  width: 100%;
  height: auto;
  /* White, always. A ticket is printed on paper, and a dark surround behind a white page
     is what makes it read as a document rather than as a screenshot of the app. */
  background: #fff;
}

.original-note {
  margin: 0;
  padding: 11px 16px;
  background: var(--surface-solid);
  color: var(--label-2);
  font-size: 12.5px;
  line-height: 1.4;
  text-align: center;
}

.action {
  border: 0;
  padding: 16px;
  background: var(--surface-solid);
  color: var(--accent);
  font: inherit;
  font-size: 17px;
  cursor: pointer;
}

.action:active { background: var(--fill); }
.action.danger { color: var(--red); }

/* Set apart, so "Cancel" is never mistaken for one of the actions. */
.action.cancel {
  margin-top: 8px;
  border-radius: 15px;
  font-weight: 600;
}

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes sheet-up {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: translateY(0); }
}

.toast span { font-size: 13px; opacity: 0.82; }

/*
 * A toast the user can act on: it stays until tapped, so it says so.
 * Text wraps rather than truncating — an error message that ends mid-sentence cannot be
 * reported, which is worse than the bug it describes.
 */
.toast.tappable { cursor: pointer; }

/*
 * What tapping will do, in the toast's own words.
 *
 * This used to be a hard-coded "Tap to dismiss", which was wrong for every toast that
 * offered an action: the delete toast read "Tap to undo" and "TAP TO DISMISS" one line
 * apart, telling the user two different things about the same tap. The caller now
 * supplies the verb and it is set here.
 */
.toast.tappable::after {
  content: attr(data-action);
  margin-top: 4px;
  font-size: 11px;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  opacity: 0.55;
}

.toast strong,
.toast span {
  overflow-wrap: anywhere;
  white-space: normal;
}

@keyframes toast-in {
  from { opacity: 0; transform: translate(-50%, 12px); }
  to { opacity: 1; transform: translate(-50%, 0); }
}
