/* ============ roman — official site ============ */

/* QuoteNote appears as OUTLINED VECTOR ARTWORK, never as a served font file.
   Licence: Fontspring Desktop/Ebook EULA 1.8 §1a permits using the font to
   create artwork for screens as "a static vector … made with a 'create
   outlines' tool". §5 excludes @font-face embedding, so there is deliberately
   no @font-face rule and no font file in site/. Source .otf + the EULA live in
   reference/fonts-licensed/ (never deployed). To regenerate or add a heading,
   see reference/fonts-licensed/README.md. */
.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.qn { display: block; height: auto; fill: currentColor; }
/* The hero wordmark is an outlined SVG whose width comes from font-size (width:Xem).
   "roman ellis" is 6.86em against "roman"'s 3.07em — 2.24x wider — so leaving the
   font-size alone would have pushed it straight off the side. Sizing the hero mark
   off its CONTAINER instead of the type scale makes it fill the column properly and
   stay right whatever the name is; the h1 font-size now only drives line-height. */
.hero h1 .qn { width: 100%; max-width: 620px; }
/* the north star, centred over the wordmark as a lockup. Same 3D-axis spin as the
   nav mark. Its height plus margin is also what drops the name lower down the
   photo, which is the other half of the ask — .hero is flex-centred, so the block
   growing taller moves the wordmark down by half of what is added above it.
   The wrapper matters: the wordmark is 620px inside a 640px column and the h1
   carries a small optical nudge, so `margin:auto` on the star alone centred it on
   the COLUMN and left it 18px off the wordmark. Sharing one box fixes that, and
   the optical nudge moves up to the wrapper so both shift together. */
.hero-lockup { max-width: 620px; margin-left: -6px; }
.hero .hero-lockup h1 { margin-left: 0; }   /* beats .hero h1's optical nudge, which moves to the wrapper */
/* .hero-star sizing now lives with the other .nstar instances above */
@media (max-width: 640px) { .hero-lockup { margin-left: -3px; } }
.qn-brand { display: inline-block; vertical-align: -0.06em; }
.qn-intro { display: inline-block; }
.qn-wrap { position: relative; display: inline-block; }
.qn-wrap .tower {
  position: absolute; right: -.1em; bottom: .12em;
  height: 2.6em; width: auto; z-index: -1; pointer-events: none;
}
/* Photoreal, calm, night-time. The cartoon lives in world.css. */
:root {
  --bg:        #05070f;   /* near-black night */
  --bg-2:      #0b0f1d;   /* raised panels */
  --line:      rgba(255,255,255,.1);
  --ink:       #f2f4fa;
  --ink-dim:   #9aa3bd;
  --accent:     #40c057;  /* roman's green 💚 — drives the theme */
  --accent-dim: rgba(64,192,87,.4);
  --spotify:    #1DB954;  /* reserved for the Spotify button only */
  --font-disp: "Space Grotesk", "Helvetica Neue", sans-serif;
  --font-mono: "Space Mono", ui-monospace, monospace;
}

* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-disp);
  font-weight: 400;
}

/* ---------------------------------------------------------------------------
   Lock the page to vertical scrolling only.

   This was reported as the page "swerving left and right" while scrolling on a
   phone, and the giveaway was that it came and went rather than being constantly
   side-scrollable. The cause is the shooting stars: each one is placed at
   left: 3-87vw, is 110px wide, and is then translated up to 64vmin along its
   angle. On a 375px screen that is a star reaching roughly 676px — far past the
   right edge — so the document briefly grows wider EVERY TIME ONE FIRES, and a
   thumb swipe that is even slightly diagonal pans into that dead space.

   Fixed in two places, because either alone is not enough:

   1. At the source. Decorative stars are clipped to their container, so nothing
      ever extends the document in the first place.
   2. Here, as the backstop, with `clip` rather than `hidden`. They look
      equivalent but are not: overflow-x: hidden forces the other axis to `auto`,
      which turns the element into a scroll container — and a scroll container
      can still be panned programmatically and by touch on iOS, which is why
      body{overflow-x:hidden} alone never reliably fixed this. `clip` clips
      without creating a scroll container, so there is nothing left to pan.
      overscroll-behavior-x kills the horizontal rubber-band on top.

   The `hidden` line is a fallback for engines predating `clip`; anything that
   understands `clip` takes the second line.
   --------------------------------------------------------------------------- */
html, body {
  overflow-x: hidden;
  overflow-x: clip;
  overscroll-behavior-x: none;
}
/* The gesture lock, and the only layer that covers position:fixed elements and
   browsers too old for `clip`. pan-y allows normal vertical scrolling and
   nothing sideways; pinch-zoom is listed explicitly so this does NOT take zoom
   away — dropping it would be an accessibility regression, not a tidy-up.
   If a horizontally-scrollable component is ever added, give that element
   touch-action: auto rather than weakening this. */
body { touch-action: pan-y pinch-zoom; }
img { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }

/* ---------- quiet starfield ---------- */
/* overflow:hidden is load-bearing, not cosmetic. Shooting stars are absolutely
   positioned inside here at up to 87vw and then translated up to 64vmin along
   their angle, so without clipping they hang well past the right edge and widen
   the document every time one fires — which is what made the page swerve
   sideways on a phone. Clip them here and the page can only ever scroll down. */
.starfield { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.star {
  position: absolute; border-radius: 50%;
  background: #dfe6ff;
  animation: twinkle var(--tw, 5s) ease-in-out infinite;
  animation-delay: var(--td, 0s);
}
@keyframes twinkle {
  0%, 100% { opacity: .18; }
  50%      { opacity: .9; }
}

/* shooting stars — coloured streaks across the night */
.shooter {
  position: absolute;
  width: 110px; height: 2px;
  background: linear-gradient(90deg, transparent, var(--c, #fff) 55%, #fff);
  border-radius: 2px;
  filter: drop-shadow(0 0 6px var(--c, #fff));
  opacity: 0;
  transform: rotate(var(--angle, 20deg)) translateX(0);
  animation: shoot var(--dur, 1s) cubic-bezier(.2,.6,.4,1) forwards;
  pointer-events: none;
}
@keyframes shoot {
  0%   { opacity: 0; transform: rotate(var(--angle)) translateX(0); }
  10%  { opacity: .95; }
  70%  { opacity: .5; }
  100% { opacity: 0; transform: rotate(var(--angle)) translateX(var(--dist, 40vmin)); }
}

/* ---------- nav ---------- */
.nav {
  position: fixed; top: 0; left: 0; right: 0; z-index: 50;
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; padding: 16px 26px;
  background: linear-gradient(to bottom, rgba(5,7,15,.9), rgba(5,7,15,0));
  transition: background .3s ease;
}
/* once scrolled: near-solid bar + a soft fade zone below, so passing
   content dims out before it touches the header */
.nav.scrolled { background: rgba(5,7,15,.95); }
/* --ng (0→1) is set from scroll position in main.js, so the header's light
   bleeds in gradually as the page rises behind it rather than snapping on */
.nav::after {
  content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 76px;
  background:
    linear-gradient(to bottom, rgba(5,7,15, calc(.95 * var(--ng, 0))),
                               rgba(5,7,15, 0) 64%),
    linear-gradient(to bottom, rgba(64,192,87, calc(.22 * var(--ng, 0))),
                               rgba(64,192,87, calc(.075 * var(--ng, 0))) 8%,
                               rgba(64,192,87, 0) 17%);
  pointer-events: none;
}
/* a thin green light along the header's edge, with a slow shimmer travelling it */
.nav::before {
  content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 1px;
  background:
    linear-gradient(90deg, transparent, rgba(255,255,255,.9), transparent) no-repeat,
    linear-gradient(90deg, rgba(64,192,87,0), rgba(64,192,87,.5) 22%,
                           rgba(64,192,87,.62) 50%, rgba(64,192,87,.5) 78%,
                           rgba(64,192,87,0));
  background-size: 22% 100%, 100% 100%;
  background-position: -30% 0, 0 0;
  opacity: var(--ng, 0);
  box-shadow: 0 0 7px rgba(64,192,87, calc(.46 * var(--ng, 0)));
  animation: navShimmer 9s ease-in-out infinite;
  pointer-events: none;
}
@keyframes navShimmer {
  0%, 12%  { background-position: -30% 0, 0 0; }
  55%, 100% { background-position: 130% 0, 0 0; }
}
.nav-brand {
  font-family: var(--font-disp); font-weight: 700;
  font-size: 22px; letter-spacing: .02em;
  color: var(--ink);
  display: inline-flex; align-items: center; gap: 9px;
}
.nav-brand > svg:not(.qn) {
  display: block;
  animation: starSpin 9s linear infinite;
  transform-origin: 50% 50%;
}
/* 3D turn on its vertical axis — swings deep but never goes edge-on invisible */
/* A true 360, constant speed, so the R comes round and then the E. The old
   keyframe swung to 65deg and back and never completed a turn, so a back face
   could never be seen. Safe for the plain stars too — the star is symmetrical
   about its vertical axis, so a half turn looks identical to none. */
@keyframes starSpin {
  from { transform: perspective(300px) rotateY(0deg); }
  to   { transform: perspective(300px) rotateY(360deg); }
}
.nav-brand > svg:not(.qn), .foot-star, .gate-star { animation-timing-function: linear; }
.nav-links {
  display: flex; flex-wrap: wrap; gap: 26px; align-items: center;
  font-family: var(--font-mono); font-size: 13px;
  margin-left: auto; margin-right: 14px;
}
.nav-world { font-family: var(--font-mono); font-size: 13px; }
.nav-links a { color: var(--ink-dim); transition: color .2s; }
.nav-links a:hover { color: var(--ink); }
.nav-world {
  border: 1px solid rgba(255,255,255,.55);
  padding: 6px 12px; border-radius: 3px;
  background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.02));
  transition: background .2s, border-color .2s, transform .18s cubic-bezier(.2,.7,.3,1), box-shadow .2s;
}
.nav-world .nw-b { color: var(--accent); }  /* roman's = green */
.nav-world .nw-w { color: #f2f4fa; }
.nav-world .nw-o { color: #4dabf7; }        /* world = blue */
.nav-world:hover {
  background: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,.05));
  border-color: #fff;
  transform: translateY(-1px);
  box-shadow: 0 4px 16px rgba(255,255,255,.18);
}
.nav-world:active { transform: translateY(1px) scale(.97); box-shadow: none; }

/* ---------- hero ---------- */
.hero {
  position: relative; z-index: 1;
  min-height: 100svh;
  display: flex; align-items: center;
  padding: 90px 6vw 60px;
}
.hero-photo {
  position: absolute; z-index: -1;
  right: 3vw; top: 50%; transform: translateY(-50%);
  width: min(62vw, 100svh, 1040px); aspect-ratio: 1;
  background: url("../assets/img/photo-bench-stars.jpg?v=o2") center / cover no-repeat;
  -webkit-mask-image: radial-gradient(ellipse 70% 70% at center, #000 45%, transparent 72%);
  mask-image: radial-gradient(ellipse 70% 70% at center, #000 45%, transparent 72%);
  opacity: .95;
  overflow: hidden;   /* holds 85 stars + localShoot() shooting stars; clip them to the photo */
  will-change: transform;
  animation: photoIn 1.8s ease .1s backwards;
}
@keyframes photoIn { from { opacity: 0; } }
/* Just HIM, matted out of the same photo, sitting above the copy so the wordmark
   passes behind him. The first attempt raised the whole photo instead — which did
   put him in front, but the photo covering the letters is itself semi-transparent
   (opacity .95, and a radial mask that fades), so it greyed the type down as well.
   A cut-out only covers where he actually is, and the letters keep full strength.
   Geometry is copied from .hero-photo exactly — same width, same aspect, same
   anchor — because both are square and 'cover' on a square box is a 1:1 fit, so
   he lands precisely over himself. Deliberately NOT masked: the mask is what
   softened him last time. main.js gives it the same parallax so it never drifts. */
.hero-cutout {
  position: absolute; z-index: 2;
  right: 3vw; top: 50%; transform: translateY(-50%);
  width: min(62vw, 100svh, 1040px); aspect-ratio: 1;
  background: url("../assets/img/hero-cutout.webp") center / cover no-repeat;
  opacity: .95;                 /* matches .hero-photo so he does not pop brighter */
  pointer-events: none;
  animation: photoIn 1.8s ease .1s backwards;
}
.hero-grad {
  position: absolute; inset: 0; z-index: -1;
  background: linear-gradient(100deg, rgba(5,7,15,.9) 30%, rgba(5,7,15,.25) 60%, rgba(5,7,15,0) 80%);
}
/* dedicated sky above the headline — sits over the darkening gradient */
.hero-sky {
  position: absolute; left: 0; right: 45%; top: 0; height: 34%;
  z-index: 0; pointer-events: none;
  overflow: hidden;   /* localShoot() puts shooting stars in here too — same reason as .starfield */
}
.hero-content { max-width: 640px; width: 100%; position: relative; z-index: 1; }
/* .hero is a flex row, so without width:100% .hero-content shrinks to its widest
   child. That left the ticket strip short of the right gutter on phones, and once
   the wordmark was sized from its container rather than font-size it made the
   hero mark shrink on desktop too. */
.hero h1 {
  font-size: clamp(72px, 12.5vw, 176px);
  font-weight: 700; line-height: .9; letter-spacing: -.02em;
  margin-left: -6px;
  animation: riseIn .9s cubic-bezier(.2,.7,.25,1) .15s backwards;
}
.hero-sub {
  margin-top: 22px; font-size: clamp(17px, 1.5vw, 22px); color: var(--ink-dim);
  animation: riseIn .8s cubic-bezier(.2,.7,.25,1) .35s backwards;
}
.hero .btn-row { animation: riseIn .8s cubic-bezier(.2,.7,.25,1) .5s backwards; }
.nav { animation: fadeDown .7s ease .05s backwards; }
@keyframes riseIn { from { opacity: 0; transform: translateY(26px); } }
@keyframes fadeDown { from { opacity: 0; transform: translateY(-12px); } }
.hero-sub strong { color: var(--ink); font-weight: 600; letter-spacing: .04em; }
/* The green divider that replaced the em dashes. Deliberately NOT scoped to
   .hero-sub — it's used in body copy too, and a span's own colour beats the
   colour inherited from .copy p, so it stays green wherever it lands. */
.sep { color: var(--accent); font-weight: 400; margin: 0 .1em; }
.heart {
  display: inline-block;
  filter: saturate(1.1);
  animation: heartSpin 3.6s ease-in-out 1.4s infinite;
}
@keyframes heartSpin {
  0%   { transform: rotateY(0); }
  38%  { transform: rotateY(360deg); }
  100% { transform: rotateY(360deg); }
}

.btn-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 30px; }
.btn {
  font-family: var(--font-mono); font-size: 14px;
  padding: 12px 22px; border-radius: 3px;
  border: 1px solid var(--line);
  color: var(--ink);
  background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,0));
  transition: border-color .2s, background .2s,
              transform .18s cubic-bezier(.2,.7,.3,1), box-shadow .2s;
}
.btn:hover {
  border-color: rgba(255,255,255,.45); transform: translateY(-2px);
  box-shadow: 0 6px 18px rgba(0,0,0,.35);
}
.btn:active { transform: translateY(1px) scale(.96); transition-duration: .06s; box-shadow: none; }
.btn-solid {
  background: linear-gradient(180deg, #24cd62, #159a44);
  border-color: #1DB954;
  color: #04120a; font-weight: 700;
  box-shadow: 0 6px 18px rgba(29,185,84,.28), inset 0 1px 0 rgba(255,255,255,.28);
}
.btn-solid:hover {
  background: linear-gradient(180deg, #2bdd6d, #1bab4f);
  border-color: #1ed760;
  box-shadow: 0 10px 26px rgba(29,185,84,.42), inset 0 1px 0 rgba(255,255,255,.3);
}
.btn-solid:active { box-shadow: 0 3px 10px rgba(29,185,84,.3), inset 0 1px 0 rgba(255,255,255,.2); }
.btn-sm { padding: 8px 14px; font-size: 12px; }
a:focus-visible, button:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 3px;
}
/* The logo used to carry role=button + tabindex=0 so it could open the notes
   panel, which made it focusable WITHOUT matching the rule above — it picked up
   the browser's own default focus box, and phones drew a grey tap-highlight on
   top. That feature is gone, but suppressing the tap-highlight still matters for
   every link and button on a phone. Keyboard users keep a green :focus-visible
   ring either way. */
a, button, [role="button"], [tabindex] {
  -webkit-tap-highlight-color: transparent;
}
.nav-brand > svg:not(.qn), .foot-star, .gate-star { outline: none; }
.nav-brand > svg:not(.qn):focus-visible,
.foot-star:focus-visible, .gate-star:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px;
}

.scroll-cue {
  position: absolute; bottom: 22px; left: 50%; transform: translateX(-50%);
  color: var(--ink-dim); opacity: .8;
  animation: cueBob 2.2s ease-in-out infinite;
}
@keyframes cueBob {
  0%, 100% { transform: translate(-50%, 0); }
  50%      { transform: translate(-50%, 7px); }
}

/* ---------- blocks ---------- */
main { position: relative; z-index: 1; }
.block, section[id] { scroll-margin-top: 84px; }
.block { padding: 110px 6vw; border-top: 1px solid rgba(255,255,255,.05); }
.block-head { max-width: 1060px; margin: 0 auto 40px; }
.block-inner {
  max-width: 1060px; margin: 0 auto;
  display: grid; grid-template-columns: 1fr 1.1fr;
  gap: 64px; align-items: center;
}
.block-inner.flip { grid-template-columns: 1.1fr 1fr; }
.block-inner.flip .photo-card { order: 2; }
.block-inner.flip .copy { order: 1; }

.eyebrow {
  font-family: var(--font-mono); font-size: 12px; letter-spacing: .14em;
  color: var(--accent); margin-bottom: 12px; text-transform: lowercase;
}
h2 {
  font-size: clamp(30px, 4vw, 46px);
  font-weight: 600; letter-spacing: -.01em; line-height: 1.05;
  margin-bottom: 16px;
}
/* Blackpool Tower line-art rising behind the word (see .qn-wrap above) */
.tower .frame { stroke: rgba(255,255,255,.45); }
.tower .lite { animation: twinkle var(--tw, 3s) ease-in-out var(--td, 0s) infinite; }

.copy p { color: var(--ink-dim); font-size: 17px; line-height: 1.65; margin-bottom: 12px; }
/* .copy p (0,1,1) sits LATER in this file than .eyebrow (0,1,0), so it won on
   source order and every eyebrow inside a copy column rendered in body grey — which
   is why "about", "show dates" and "live" looked different to "music" and
   "socials", which sit in .block-head instead. This wins at (0,2,1) and restores
   the type size the body rule was also overriding. */
.copy p.eyebrow {
  color: var(--accent); font-size: 12px; line-height: 1.4; margin-bottom: 12px;
}
.copy strong, .copy em { color: var(--ink); font-style: normal; }
/* roman's sign-off at the end of the about copy. Full-strength ink rather than
   the body grey, so it reads as a signature rather than another sentence. */
.copy p.sign-off { color: var(--ink); margin-top: 20px; line-height: 1.5; }
.sign-x { color: var(--accent); font-weight: 700; }
/* roman signs off with 🌍💚. A hair of leading space so it does not crowd his
   name, and no animation — the hero heart already spins and a second moving
   emoji two screens down is one too many. */
.sign-globe { margin-left: .28em; }
.small-note { font-family: var(--font-mono); font-size: 12px !important; opacity: .75; margin-top: 16px; }

.photo-card {
  position: relative;
  border-radius: 10px; overflow: hidden;
  border: 1px solid var(--line);
  background: var(--bg-2);
}
/* height:auto matters: these carry width/height attributes to reserve space and
   stop layout shift, but without it the HTML height attribute wins over width:100%
   and the image renders stretched. That is what squashed the live-on-tap shot to
   428x1080 from a square source. */
.photo-card img { width: 100%; height: auto; transform: scale(1.08); will-change: transform; }
.photo-card figcaption {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 22px 16px 12px;
  font-family: var(--font-mono); font-size: 12px; color: rgba(255,255,255,.85);
  background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
}

/* ---------- music ---------- */
.music-grid {
  max-width: 1060px; margin: 0 auto;
  display: grid; grid-template-columns: 1.3fr 1fr;
  gap: 40px; align-items: start;
}
.releases { display: flex; flex-direction: column; gap: 16px; }
.release {
  display: flex; align-items: center; gap: 16px;
  border: 1px solid var(--line); border-radius: 10px;
  padding: 12px;
  background: linear-gradient(180deg, #0e1326, #090d1a);
  transition: border-color .2s, transform .18s cubic-bezier(.2,.7,.3,1), box-shadow .2s;
}
.release:hover {
  border-color: rgba(255,255,255,.35); transform: translateY(-2px);
  box-shadow: 0 8px 22px rgba(0,0,0,.4);
}
.release:active { transform: translateY(0) scale(.985); transition-duration: .06s; }
/* cascade in when the column reveals */
.releases:not(.in) .release, .releases:not(.in) .release-links { opacity: 0; }
.releases.in .release { animation: slideIn .6s cubic-bezier(.2,.7,.3,1) backwards; }
.releases.in .release:nth-child(2) { animation-delay: .1s; }
.releases.in .release:nth-child(3) { animation-delay: .2s; }
.releases.in .release:nth-child(4) { animation-delay: .3s; }
.releases.in .release-links { animation: slideIn .6s cubic-bezier(.2,.7,.3,1) .42s backwards; }
@keyframes slideIn { from { opacity: 0; transform: translateX(24px); } }
.release img { width: 76px; height: 76px; border-radius: 6px; object-fit: cover; }
.r-meta { display: flex; flex-direction: column; gap: 4px; }
.r-meta strong { font-size: 17px; font-weight: 600; }
.r-meta em { font-family: var(--font-mono); font-style: normal; font-size: 12px; color: var(--ink-dim); }
.release-links { display: flex; gap: 10px; margin-top: 6px; }


/* ---------- socials ---------- */
.social-row {
  max-width: 1060px; margin: 0 auto;
  display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;
}
.social {
  border: 1px solid var(--line); border-radius: 10px;
  padding: 22px 18px;
  background: linear-gradient(180deg, #0e1326, #090d1a);
  display: flex; flex-direction: column; gap: 6px;
  transition: border-color .2s, transform .18s cubic-bezier(.2,.7,.3,1), box-shadow .2s;
}
.social:hover {
  border-color: rgba(255,255,255,.35); transform: translateY(-3px);
  box-shadow: 0 8px 22px rgba(0,0,0,.4);
}
.social:active { transform: translateY(0) scale(.98); transition-duration: .06s; }
.social-row:not(.in) .social { opacity: 0; }
.social-row.in .social { animation: riseIn .6s cubic-bezier(.2,.7,.3,1) backwards; }
.social-row.in .social:nth-child(2) { animation-delay: .08s; }
.social-row.in .social:nth-child(3) { animation-delay: .16s; }
.social-row.in .social:nth-child(4) { animation-delay: .24s; }
.s-name { font-weight: 600; font-size: 17px; }
.s-handle { font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim); }
.s-handle .at { color: var(--accent); }

/* ---------- the door to the world ---------- */
.portal-section {
  position: relative; z-index: 1;
  padding: 120px 6vw 140px;
  border-top: 1px solid rgba(255,255,255,.05);
  text-align: center;
}
.portal-wrap { display: inline-block; }
.portal {
  position: relative; display: block;
  width: min(460px, 86vw);
  border-radius: 14px 14px 6px 6px;
  overflow: visible;
  transition: transform .3s ease;
}
.portal-scene {
  width: 100%; aspect-ratio: 2.35 / 1;
  object-fit: cover; object-position: center 20%;
  border-radius: 14px 14px 6px 6px;
  border: 1px solid rgba(64,192,87,.45);
}
.portal::before {
  content: ""; position: absolute; inset: 0; z-index: 0;
  border-radius: 14px 14px 6px 6px; pointer-events: none;
  box-shadow: 0 0 60px rgba(64,192,87,.34), 0 0 150px rgba(64,192,87,.13);
  opacity: .5;
  animation: glowPulse 5.5s ease-in-out infinite;
}
/* ---------- glow pulses: static shadow, animated opacity ----------
   These four effects all animated `box-shadow` on an infinite loop. box-shadow is a
   PAINT property — it is not compositor-accelerated, so every frame the browser had to
   re-rasterise a blurred shadow (up to 150px of blur) for as long as the page was open.
   Six of them ran continuously. That was the single largest cause of the phone feeling
   sticky while scrolling.
   The fix keeps the look exactly: the halo pseudo-element now carries a STATIC shadow at
   the pulse's peak, and only its `opacity` animates. Opacity is compositor-only — the
   layer is rasterised once and the GPU fades it for free. Same breathing glow, no
   per-frame repaint. Applied at every width; there is no downside on desktop either. */
@keyframes glowPulse {
  0%, 100% { opacity: .5; }
  50%      { opacity: 1; }
}
.portal:hover { transform: scale(1.02); }
.portal-gdave {
  position: absolute; right: -34px; bottom: -6px;
  width: 74px; aspect-ratio: 120 / 180;   /* reserve the box before it loads */
  transform: rotate(6deg);
  transition: transform .3s ease;
}
.portal:hover .portal-gdave { transform: rotate(0deg) translateY(-4px); }
.portal-label {
  position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%);
  z-index: 3;
  font-family: var(--font-mono); font-size: 14px; font-weight: 700;
  background: rgba(5,7,15,.85); border: 1px solid rgba(255,255,255,.5);
  padding: 9px 18px; border-radius: 3px;
  white-space: nowrap;
}
.portal-label .nw-b { color: var(--accent); }   /* roman's = green */
.portal-label .nw-w { color: #f2f4fa; }
.portal-label .nw-o { color: #4dabf7; }         /* world = navy blue */
.portal-line {
  margin-top: 26px;
  font-family: var(--font-mono); font-size: 13px; color: var(--ink-dim);
}

/* ---------- footer ---------- */
footer {
  position: relative; z-index: 1;
  text-align: center; padding: 40px 20px 50px;
  border-top: 1px solid rgba(255,255,255,.05);
}
.foot-star {
  display: block; margin: 0 auto 14px;
  animation: starSpin 9s linear infinite;
  transform-origin: 50% 50%;
}
.foot-line { font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim); }

/* ---------- reveal ---------- */
.reveal { opacity: 0; transform: translateY(22px); transition: opacity .7s ease, transform .7s ease; }
.reveal.in { opacity: 1; transform: translateY(0); }

/* ---------- the gate (login to the world) ---------- */
.gate { position: fixed; inset: 0; z-index: 140; display: flex; justify-content: center; overflow-y: auto; padding: 16px; }
.gate[hidden] { display: none; }
.gate-veil {
  position: absolute; inset: 0;
  background: rgba(3, 4, 10, .78);
  backdrop-filter: blur(5px);
  -webkit-backdrop-filter: blur(5px);
}
.gate-panel {
  position: relative;
  margin: auto;
  height: fit-content;
  width: min(360px, 92vw);
  padding: 36px 30px 30px;
  text-align: center;
  background: linear-gradient(180deg, #0e1326, #090d1a);
  border: 1px solid var(--accent);          /* thin orange border */
  border-radius: 14px;
  animation: gateIn .35s cubic-bezier(.2,.7,.25,1.1) backwards;
}
/* the humming blue backlight, as a static shadow on a pseudo whose opacity is
   what animates. Same technique as the other four glows: box-shadow is a paint
   property, and this one sat on top of the gate's full-viewport backdrop-filter,
   so re-blurring it every frame was the most expensive of the lot. z-index -1
   keeps it behind the panel's own background, which is what a backlight is. */
.gate-panel::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  border-radius: 14px; pointer-events: none;
  box-shadow: 0 0 46px rgba(77,171,247,.5), 0 0 130px rgba(77,171,247,.2);
  opacity: .45;
  animation: gateHum 3.2s ease-in-out infinite;
}
@keyframes gateIn { from { opacity: 0; transform: scale(.92) translateY(10px); } }
@keyframes gateHum {
  0%, 100% { opacity: .45; }
  50%      { opacity: 1; }
}
.gate-panel.shake { animation: gateShake .45s ease; }   /* hum lives on ::before now */
@keyframes gateShake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-9px); } 40% { transform: translateX(9px); }
  60% { transform: translateX(-6px); } 80% { transform: translateX(6px); }
}
/* .nstar already carries the spin and origin — this only sets the size. */
.nstar.gate-star { display: block; width: 34px; margin: 0 auto 14px; }
.gate-title { font-family: var(--font-mono); font-size: 19px; font-weight: 700; letter-spacing: .02em; }
.gate-title .nw-b { color: var(--accent); } .gate-title .nw-w { color: #f2f4fa; } .gate-title .nw-o { color: #4dabf7; }
.gate-sub { font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim); margin: 8px 0 22px; }
.gate input {
  display: block; width: 100%; margin-bottom: 12px;
  padding: 12px 14px;
  font-family: var(--font-mono); font-size: 14px;
  color: var(--ink);
  background: #0a0e1b;
  border: 1px solid var(--line); border-radius: 6px;
  outline: none;
  transition: border-color .2s, box-shadow .2s;
}
.gate input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(64,192,87,.15); }
.gate input::placeholder { color: #5b6480; }
.gate-enter { width: 100%; margin-top: 6px; border-color: var(--accent-dim); color: var(--accent); }
.gate-enter:hover { border-color: var(--accent); background: rgba(64,192,87,.1); }
.gate-err { font-family: var(--font-mono); font-size: 12px; color: #ff6b6b; margin-top: 14px; min-height: 15px; }
.gate-close {
  position: absolute; top: 10px; right: 14px;
  background: none; border: none; cursor: pointer;
  font-size: 22px; color: var(--ink-dim);
  transition: color .2s, transform .2s;
}
.gate-close:hover { color: var(--ink); transform: rotate(90deg); }
.gate-gdave {
  position: absolute; right: -32px; bottom: -10px;
  width: 72px; aspect-ratio: 120 / 180;
  transform: rotate(7deg);
  animation: gdavePeek .55s cubic-bezier(.2,.7,.3,1.2) .35s backwards;
  pointer-events: none;
  filter: drop-shadow(0 6px 12px rgba(0,0,0,.5));
}
@keyframes gdavePeek { from { opacity: 0; transform: translateY(24px) rotate(14deg); } }
@media (max-width: 900px) { .gate-gdave { right: -12px; width: 60px; } }

/* looping clip sits in a photo-card exactly like an image */
.photo-card video { width: 100%; display: block; transform: scale(1.08); will-change: transform; }

/* ---------- the tiktok prism ----------
   A four-sided box that turns on its vertical axis, one quarter-turn at a time,
   showing a different clip on each face. Only four faces exist no matter how
   many clips there are — after each turn, main.js loads the next clip into the
   face that has just rotated out of sight, so it recycles forever.
   --d is half the stage width (the prism's depth) and is set from JS, because
   translateZ can't take a percentage. --i is the turn counter. */
.tok-stage {
  position: relative; width: 100%; aspect-ratio: 9 / 16;
  perspective: 1500px;
}
.tok-cube {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
  /* pull back by the depth so the front face stays flush with the frame
     instead of appearing to lunge forward */
  transform: translateZ(calc(var(--d, 0px) * -1)) rotateY(calc(var(--i, 0) * -90deg));
  transition: transform 1.15s cubic-bezier(.72,.02,.24,1);
  will-change: transform;
}
.tok-face {
  position: absolute; inset: 0;
  overflow: hidden;
  backface-visibility: hidden;
  background: var(--bg-2);
  transform: rotateY(calc(var(--k) * 90deg)) translateZ(var(--d, 0px));
}
/* the generic .photo-card rules above set width:100% + scale(1.08) on videos,
   which would break the faces — reset them here */
.tok-face video {
  width: 100%; height: 100%; object-fit: cover;
  display: block; transform: none; will-change: auto;
}
/* the leading edge catches a little light as it turns, so the box reads solid */
.tok-face::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(90deg, rgba(0,0,0,.45), transparent 22%, transparent 78%, rgba(0,0,0,.45));
}
.tok figcaption a { color: inherit; }
.tok figcaption a:hover { color: var(--accent); }
/* paused by hover, touch or keyboard focus */
.tok.is-held .tok-cube { transition-duration: .5s; }

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

/* ---------- toast ---------- */
.toast {
  position: fixed; left: 50%; bottom: 26px; z-index: 99;
  transform: translate(-50%, 80px);
  /* left:50% makes the shrink-to-fit width only half the viewport, so on a
     320px phone the toast rendered 160px wide and wrapped to five lines.
     max-width alone can't fix that — a shrink-to-fit box needs an intrinsic
     width first. Desktop is unaffected: max-content is well under 88vw there. */
  width: max-content; max-width: 88vw;
  background: var(--bg-2);
  border: 1px solid var(--accent);
  color: var(--ink);
  font-family: var(--font-mono); font-size: 13px;
  padding: 12px 20px; border-radius: 8px;
  opacity: 0; pointer-events: none;
  transition: transform .35s ease, opacity .35s ease;
  box-shadow: 0 12px 30px rgba(0,0,0,.5);
}
.toast.show { transform: translate(-50%, 0); opacity: 1; }

/* ---------- mobile ---------- */
@media (max-width: 900px) {
  .gate input { font-size: 16px; }
  .nav { padding: 12px 16px; flex-wrap: wrap; align-items: center; row-gap: 10px; }
  .nav-brand { order: 0; }
  .nav-world { order: 1; margin-left: auto; font-size: 12px; padding: 5px 10px; }
  .nav-links { order: 2; flex-basis: 100%; margin: 0; gap: 6px; font-size: 12px; }
  .nav-links a { padding: 12px 8px; }   /* 44px-tall tap targets */
  .nav-world { padding: 11px 12px; }
  .hero { padding-top: 130px; align-items: flex-end; }
  .hero-photo, .hero-cutout {
    right: 50%; transform: translate(50%, 0); top: 6%;
    /* was min(520px, 100vw) — i.e. full-bleed to both edges on every phone, and
       tall enough that the mask's faded tail reached down into the star. 92vw
       gives it a margin like everything else on the page, and the smaller box
       pulls the fade clear of the lockup on short phones too. */
    width: min(460px, 92vw);
  }
  .hero-photo { opacity: .8; }
  /* The cut-out is a DESKTOP-ONLY device and actively wrong here. Its whole job
     is to let the wordmark pass behind him, which only means anything in the
     two-column layout where the copy sits beside the photo. On a phone the photo
     is centred ABOVE the copy, and the cut-out — the one layer with no mask, so
     hard-edged to its full square — lands dead centre at z-index 2, i.e. on top
     of .hero-content. He was being painted over the star at .8 opacity, which is
     exactly the greyed-out look we killed on desktop, back again by another
     route. Nothing is lost by dropping it: .hero-photo already contains him, and
     phones skip a 37KB fetch. */
  .hero-cutout { display: none; }
  .hero-grad { background: linear-gradient(to top, rgba(5,7,15,.95) 25%, rgba(5,7,15,.3) 60%, rgba(5,7,15,.1)); }
  /* svh, not vh. vh is the LARGE viewport height on a phone, so as the browser's
     URL bar collapses during scroll this padding grew by ~8px and the whole hero
     block crept upward mid-scroll. svh is the small-viewport height and does not
     move, so the hero stays put whatever the chrome does. */
  .hero-content { padding-bottom: 8svh; }
  .block { padding: 70px 20px; }
  .block-inner, .block-inner.flip { grid-template-columns: 1fr; gap: 36px; }
  .block-inner.flip .photo-card { order: 0; }
  .block-inner.flip .copy { order: 1; }
  .music-grid { grid-template-columns: 1fr; }
  .social-row { grid-template-columns: 1fr 1fr; }
  .portal-gdave { right: -18px; }
}

/* ---------- opening titles ---------- */
.intro { display: none; }
html.intro-run .intro { display: block; position: fixed; inset: 0; z-index: 200; }
.intro-veil { position: absolute; inset: 0; background: var(--bg); }
.intro-stars { position: absolute; inset: 0; overflow: hidden; }
/* ---------------------------------------------------------------------------
   THE OPENING SEQUENCE
   Four beats: the mark alone, spinning hard -> it settles to its true size ->
   the name slides out from behind it -> a pause -> it flies to the corner and
   locks into the nav.
   The whole thing moves ONLY transforms, opacity and clip-path. The lockup is
   laid out in its FINAL arrangement from the very first frame and never
   reflows; stage one merely slides it sideways so the star happens to sit dead
   centre. Nothing here can cause a layout shift, which is exactly what you want
   on the one screen every single visitor sees.
   --------------------------------------------------------------------------- */
.intro-lockup {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  gap: clamp(16px, 3vw, 32px);
  /* --intro-shift is half the name's width, measured in JS. It puts the star
     dead centre while the name is still clipped away. */
  transform: translateX(var(--intro-shift, 0px));
  transition: transform .85s cubic-bezier(.2,.75,.25,1);
  will-change: transform;
}
@keyframes lockupIn { from { opacity: 0; transform: scale(.9); } to { opacity: 1; transform: none; } }
/* Both clamps used to bottom out too high — 64px and 44px — and those floors bite
   exactly where there is least room. On a 375px phone the vw terms want 56px and
   37px, but the minimums forced 64 and 44, so the lockup measured ~367px inside
   375: it touched both edges. The floors now sit below what any phone asks for,
   so the vw term is what actually governs, and the lockup lands at roughly 78%
   of the viewport all the way down to a 320px screen. Desktop is untouched — it
   has been pinned to the max since ~870px wide. If you retune these, the width
   to keep an eye on is the wordmark's: it is 6.8645em, so it grows ~6.9px for
   every 1px of font-size, and the star and gap add ~55px on top. */
/* Width, not height: it is an .nstar now, and .nstar svg is width-driven. These
   numbers are the old height clamp translated through the 64:84 viewBox, so the
   lockup keeps the footprint that was tuned to stop it touching the edges. */
.nstar.intro-star {
  width: clamp(35px, 9.9vw, 92px);
  flex: 0 0 auto;
  opacity: 0;
  /* The spin animates `transform`, so the scale MUST be the standalone `scale`
     property — those compose, whereas a second `transform` would simply lose to
     the animation. */
  scale: var(--intro-boost, 2.15);
  transition: scale .75s cubic-bezier(.2,.75,.25,1), opacity .45s ease;
  /* Fast spin that eases to rest, then the ordinary slow spin takes over. The
     hand-off is seamless because introSpin ends at 1440deg — an exact multiple
     of 360 — so starSpin picking up from 0deg is the same frame. */
  animation: introSpin 2.3s cubic-bezier(.15,.8,.3,1) forwards,
             starSpin 9s linear 2.3s infinite;
}
/* Three turns, not four. The mark is flat, so a rotateY passes edge-on twice per
   turn — at four turns in 2.3s that reads as a strobe and neither the R nor the E
   ever gets a legible moment. Three still opens fast (the ease front-loads most
   of the rotation) and lets each face land. Ends on 1080deg, an exact multiple of
   360, so the infinite starSpin picks up from 0deg on the very same frame and
   there is no jump at the hand-off. */
@keyframes introSpin {
  0%   { transform: perspective(300px) rotateY(0deg); }
  58%  { transform: perspective(300px) rotateY(720deg); }
  100% { transform: perspective(300px) rotateY(1080deg); }
}

/* beat 1 — the mark arrives */
.intro.lit .nstar.intro-star     { opacity: 1; }
/* beat 2 — it settles to the size it will actually be */
.intro.settled .nstar.intro-star { scale: 1; }
/* beat 3 — the name is drawn out from behind it, left to right */
.intro.opened .intro-lockup      { transform: translateX(0); }
.intro.opened .intro-name        { clip-path: inset(0 0 0 0); transform: translateX(0); opacity: 1; }
.intro-name {
  font-family: var(--font-disp); font-weight: 700;
  font-size: clamp(28px, 9.2vw, 80px); letter-spacing: .02em; color: var(--ink);
  /* Clipped to nothing and tucked left, so it reads as being pulled OUT of the
     star rather than fading in beside it. */
  clip-path: inset(0 100% 0 0);
  transform: translateX(-22px);
  opacity: 0;
  transition: clip-path .85s cubic-bezier(.2,.75,.25,1),
              transform .85s cubic-bezier(.2,.75,.25,1),
              opacity .3s ease;
}
html.intro-go .intro-veil, html.intro-go .intro-stars { opacity: 0; transition: opacity .9s ease; }
html.intro-go .intro { pointer-events: none; }
/* the site holds its breath until the titles fly up to the corner */
html.intro-run:not(.intro-go) .nav,
html.intro-run:not(.intro-go) .hero h1,
html.intro-run:not(.intro-go) .hero-sub,
html.intro-run:not(.intro-go) .hero .btn-row,
html.intro-run:not(.intro-go) .ticket-strip,
html.intro-run:not(.intro-go) .hero-photo { animation-play-state: paused; }

/* ---------- feel pass: swell + blue pulse on hover ---------- */
.photo-card {
  transition: opacity .7s ease, transform .45s cubic-bezier(.2,.7,.3,1),
              box-shadow .35s ease, border-color .35s ease;
}
.photo-card:hover {
  transform: translateY(-4px) scale(1.012);
  box-shadow: 0 0 38px rgba(77,171,247,.16), 0 18px 44px rgba(0,0,0,.5);
  border-color: rgba(255,255,255,.22);
}
.release img { transition: transform .4s cubic-bezier(.2,.7,.3,1); }
.release:hover img { transform: scale(1.07); }
.release:hover { box-shadow: 0 0 30px rgba(77,171,247,.14), 0 8px 22px rgba(0,0,0,.4); }
.social:hover { box-shadow: 0 0 30px rgba(77,171,247,.14), 0 8px 22px rgba(0,0,0,.4); }
.btn:hover { transform: translateY(-2px) scale(1.02); }
.btn-solid:hover {
  box-shadow: 0 10px 26px rgba(29,185,84,.42), 0 0 44px rgba(77,171,247,.12),
              inset 0 1px 0 rgba(255,255,255,.3);
}
.nav-links a { display: inline-block; transition: color .2s, transform .2s; }
.nav-links a:hover { transform: translateY(-1px); }
.portal:hover { transform: scale(1.03); }

/* ---------- respect reduced-motion ---------- */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
  html { scroll-behavior: auto; }
}

/* ---------- tickets: hero strip + sticky book rail ---------- */
.ticket-strip {
  display: inline-flex; align-items: center; gap: 16px;
  margin-top: 20px; padding: 12px 16px 12px 14px;
  border: 1px solid var(--accent);
  border-radius: 10px;
  background: linear-gradient(100deg, rgba(64,192,87,.16), rgba(64,192,87,.05));
  position: relative;
  animation: riseIn .8s cubic-bezier(.2,.7,.25,1) .62s backwards;
  transition: transform .18s cubic-bezier(.2,.7,.3,1), box-shadow .25s, border-color .2s;
}
.ticket-strip[hidden] { display: none; }
/* the blue light breathing behind it — halo layer so hover keeps its own shadow */
.ticket-strip::before {
  content: ""; position: absolute; inset: -1px;
  border-radius: 11px; pointer-events: none;
  box-shadow: 0 0 34px rgba(77,171,247,.85), 0 0 78px rgba(77,171,247,.45),
              0 0 130px rgba(77,171,247,.2);
  opacity: .26;
  animation: bluePulse 2.9s ease-in-out 1.2s infinite;
}
@keyframes bluePulse {
  0%, 100% { opacity: .26; }
  50%      { opacity: 1; }
}
.ticket-strip:hover {
  transform: translateY(-2px);
  border-color: #6fe08a;
  box-shadow: 0 8px 26px rgba(77,171,247,.4);
}
.ticket-strip:active { transform: translateY(1px) scale(.99); }
.ts-live {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .16em;
  text-transform: uppercase;
  color: #04120a; background: var(--accent);
  padding: 4px 7px; border-radius: 3px;
  align-self: flex-start;
}
.ts-body { display: flex; flex-direction: column; gap: 3px; line-height: 1.25; }
.ts-body strong { font-size: 15px; font-weight: 600; color: var(--ink); }
.ts-body em {
  font-family: var(--font-mono); font-style: normal;
  font-size: 11.5px; color: var(--ink-dim);
}
.ts-cta {
  font-family: var(--font-mono); font-size: 13px; font-weight: 700;
  color: var(--accent); white-space: nowrap;
}

/* the tab that hangs on the right edge once you scroll */
/* The clipping context for the rail.
   A position:fixed element's containing block is the VIEWPORT, so no amount of
   overflow on an ancestor will clip it — html{overflow-x:clip} does nothing to
   it. The rail parks at translate(102%), i.e. wholly outside the right edge, and
   main.js toggles that on scroll. On iOS that off-screen box is real pannable
   area which appears and disappears as you scroll, and that is what "the page
   swerves left and right when you scroll" was.
   Giving it this wrapper makes the rail position:absolute against a box that is
   exactly the viewport, so overflow:hidden can finally clip it, and the slide
   animation is completely unchanged. */
.book-rail-clip {
  position: fixed; inset: 0; overflow: hidden;
  pointer-events: none;          /* the wrapper covers the screen — it must not eat clicks */
  z-index: 60;
}
.book-rail {
  position: absolute; right: 0; top: 50%; z-index: 1;
  pointer-events: auto;          /* ...but the rail itself is still a link */
  transform: translate(102%, -50%);
  background: linear-gradient(180deg, #4ad169, #2f9e44);
  color: #04120a;
  font-family: var(--font-mono); font-size: 13px; font-weight: 700;
  letter-spacing: .1em; text-transform: lowercase;
  padding: 18px 11px;
  border-radius: 10px 0 0 10px;
  box-shadow: -6px 0 24px rgba(0,0,0,.45), 0 0 30px rgba(64,192,87,.25);
  transition: transform .45s cubic-bezier(.2,.7,.25,1), box-shadow .25s, padding-right .2s;
  cursor: pointer;
}
.book-rail[hidden] { display: none; }
.book-rail::before {
  content: ""; position: absolute; inset: 0;
  border-radius: 10px 0 0 10px; pointer-events: none;
  box-shadow: -4px 0 34px rgba(77,171,247,.9), -10px 0 70px rgba(77,171,247,.45),
              -14px 0 110px rgba(77,171,247,.2);
  opacity: 0;
}
.book-rail.show::before { animation: bluePulseRail 2.9s ease-in-out infinite; }
@keyframes bluePulseRail {
  0%, 100% { opacity: .22; }
  50%      { opacity: 1; }
}
.book-rail span { writing-mode: vertical-rl; }
.book-rail.show { transform: translate(0, -50%); }
.book-rail.show:hover {
  padding-right: 16px;
  box-shadow: -8px 0 30px rgba(0,0,0,.5), 0 0 44px rgba(77,171,247,.5);
}
.book-rail.show:active { transform: translate(2px, -50%); }

@media (max-width: 900px) {
  .ticket-strip { gap: 12px; padding: 10px 12px; }
  .ts-body strong { font-size: 14px; }
  /* It MUST hug the edge. border-radius is 10px 0 0 10px — rounded left, square
     right — because this is a tab attached to the side of the screen. Pushing it
     7px inward left the square right corners floating in space, which reads as a
     half-drawn box, not a tab. So it stays flush; the way to make it more
     visible is to make it BIGGER, not to move it. */
  .book-rail { font-size: 12.5px; padding: 19px 19px 19px 11px; letter-spacing: .1em; }
}


/* ---------- show dates ---------- */
/* NB: `.show` is also the book rail's visible-state class (.book-rail.show).
   Every rule for a row in this list MUST be scoped to .show-list, or it lands
   on the rail as well — which turned it into a full-width bordered card. */
.show-list { list-style: none; margin-top: 22px; display: flex; flex-direction: column; gap: 14px; }
/* Each date is its own card now: a thin white edge with the same blue hum the
   hero's ticket strip and the book rail already carry, so the three ticket
   surfaces on the site read as one family. */
.show-list .show {
  position: relative;
  display: flex; align-items: center; gap: 18px;
  padding: 14px 20px 14px 16px;   /* extra on the right so the tickets button
                                     clears the card's edge instead of crowding it */
  border: 1px solid rgba(255,255,255,.92);
  border-radius: 10px;
  background: linear-gradient(180deg, #0e1326, #090d1a);
}
/* The hum. No z-index and no background on purpose: the shadow spreads OUTSIDE
   the border box, so it glows around the card without ever painting over the
   content inside it. Same timing as .ticket-strip::before so the two pulse
   together rather than beating against each other. */
.show-list .show::before {
  content: ""; position: absolute; inset: -1px;
  border-radius: 11px; pointer-events: none;
  box-shadow: 0 0 30px rgba(77,171,247,.8), 0 0 70px rgba(77,171,247,.4),
              0 0 120px rgba(77,171,247,.18);
  opacity: .26;
  animation: bluePulse 2.9s ease-in-out 1.2s infinite;
}
.s-date {
  display: flex; flex-direction: column; align-items: center;
  min-width: 52px; line-height: 1;
}
.s-date b { font-size: 26px; font-weight: 700; color: var(--ink); }
.s-date i {
  font-family: var(--font-mono); font-style: normal;
  font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--accent); margin-top: 4px;
}
.s-venue { display: flex; flex-direction: column; gap: 3px; flex: 1; }
.s-venue strong { font-size: 17px; font-weight: 600; }
.s-venue em {
  font-family: var(--font-mono); font-style: normal;
  font-size: 12px; color: var(--ink-dim);
}
.s-book {
  font-family: var(--font-mono); font-size: 12px; font-weight: 700;
  color: #04120a; background: linear-gradient(180deg, #4ad169, #2f9e44);
  /* White outline round the tickets button. A real border rather than a
     box-shadow ring, because :hover already owns box-shadow and would blow the
     ring away on hover. Padding is docked by the 1.5px the border adds, so the
     button is the same size it was and the row doesn't reflow. */
  border: 1.5px solid rgba(255,255,255,.92);
  padding: 7.5px 14.5px; border-radius: 4px; white-space: nowrap;
  transition: transform .18s cubic-bezier(.2,.7,.3,1), box-shadow .2s;
}
.s-book:hover {
  transform: translateY(-2px);
  box-shadow: 0 6px 20px rgba(64,192,87,.4), 0 0 26px rgba(77,171,247,.3);
}
.s-book:active { transform: translateY(1px) scale(.97); }
.s-soon {
  font-family: var(--font-mono); font-size: 12px;
  color: var(--ink-dim); white-space: nowrap; padding: 9px 4px;
}
.copy:not(.in) .show { opacity: 0; }
.copy.in .show { animation: slideIn .55s cubic-bezier(.2,.7,.3,1) backwards; }
.copy.in .show:nth-child(2) { animation-delay: .1s; }
.copy.in .show:nth-child(3) { animation-delay: .2s; }
@media (max-width: 900px) {
  .show-list .show { gap: 12px; }
  .s-date { min-width: 44px; }
  .s-date b { font-size: 22px; }
  .s-venue strong { font-size: 15px; }
}

/* ---------- social tabs: green light hum, staggered so it ripples ---------- */
.social { position: relative; }
.social::before {
  content: ""; position: absolute; inset: -1px;
  border-radius: 11px; pointer-events: none;
  box-shadow: 0 0 26px rgba(64,192,87,.5), 0 0 58px rgba(64,192,87,.22);
  opacity: .3;
  animation: greenHum 3.6s ease-in-out infinite;
}
@keyframes greenHum {
  0%, 100% { opacity: .28; }
  50%      { opacity: 1; }
}
.social:nth-child(2)::before { animation-delay: .9s; }
.social:nth-child(3)::before { animation-delay: 1.8s; }
.social:nth-child(4)::before { animation-delay: 2.7s; }
.social:hover::before {
  animation: none;
  box-shadow: 0 0 34px rgba(64,192,87,.6), 0 0 70px rgba(77,171,247,.3);
}

/* ---------- page breakers: a white light pings across as you scroll ---------- */
.block, .portal-section, footer { position: relative; }
.block::before, .portal-section::before, footer::before {
  content: ""; position: absolute; top: -1px; left: 0;
  height: 1px; width: 34%;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,.9) 50%, transparent);
  opacity: 0; transform: translateX(-120%);
  pointer-events: none;
}
.swept::before { animation: pingLine 1.5s cubic-bezier(.35,0,.25,1); }
.swept.rev::before { animation: pingLineRev 1.5s cubic-bezier(.35,0,.25,1); }
@keyframes pingLine {
  0%   { transform: translateX(-120%); opacity: 0; }
  12%  { opacity: 1; }
  85%  { opacity: 1; }
  100% { transform: translateX(400%); opacity: 0; }
}
/* every other divider fires the other way */
@keyframes pingLineRev {
  0%   { transform: translateX(400%); opacity: 0; }
  12%  { opacity: 1; }
  85%  { opacity: 1; }
  100% { transform: translateX(-120%); opacity: 0; }
}

/* ---------- grandma dave: peeking out from BEHIND the left edge ----------
   (she has no back, so she hides behind the panel and leans out) */
.portal-scene { position: relative; z-index: 2; }
.portal-gdave {
  right: auto; left: -46px; bottom: -4px; z-index: 1;
  transform: rotate(-7deg); transform-origin: bottom right;
  /* she leans out when the portal scrolls into view and tucks back behind the
     frame when it leaves — same gag as the login box, but reversible. The
     overshoot in the curve is what makes it read as a lean rather than a slide. */
  transition: transform .7s cubic-bezier(.18,.75,.28,1.3), opacity .45s ease;
}
/* .js-dave is added by main.js, so if the script never runs (or reduced motion
   is on) she simply stays out — she never gets stuck hidden behind the frame. */
.portal-wrap.js-dave:not(.dave-out) .portal-gdave {
  opacity: 0;
  transform: translateX(112%) rotate(9deg);
}
/* hovering only lifts her once she's actually out */
.portal-wrap:not(.js-dave) .portal:hover .portal-gdave,
.portal-wrap.dave-out .portal:hover .portal-gdave {
  transform: rotate(-3deg) translateY(-4px);
}

.gate-stage { position: relative; margin: auto; height: fit-content; }
.gate-stage .gate-panel { position: relative; z-index: 2; margin: 0; }
.gate-gdave {
  position: absolute; z-index: 1;
  right: auto; left: -46px; bottom: 14px;
  width: 72px;
  transform: rotate(-8deg); transform-origin: bottom right;
  animation: gdavePeek .55s cubic-bezier(.2,.7,.3,1.2) .35s backwards;
  pointer-events: none;
  filter: drop-shadow(-6px 6px 12px rgba(0,0,0,.55));
}
@keyframes gdavePeek { from { opacity: 0; transform: translateX(26px) rotate(6deg); } }
@media (max-width: 900px) {
  /* keep her fully on-screen: sit her just inside the panel edge on phones */
  .portal-gdave { left: -14px; right: auto; width: 54px; }
  .gate-gdave { left: -12px; right: auto; width: 52px; }
}


/* landscape phones: the login panel must not push its button off-screen */
@media (max-height: 520px) {
  .gate { align-items: flex-start; }
  .gate-panel { padding: 20px 24px 18px; }
  .gate-star { width: 24px; height: 32px; margin-bottom: 8px; }
  .gate-sub { margin: 6px 0 14px; }
  .gate input { padding: 9px 12px; margin-bottom: 8px; }
  .gate-gdave { width: 44px; bottom: 8px; }
}

/* ================= phones ==================================================
   Everything above stops at max-width:900px, which meant a 320px phone got the
   same layout as an 899px tablet. These blocks are the phone-only pass.
   ========================================================================= */

/* a 1fr grid track is really minmax(auto, 1fr) — the auto floor is the item's
   min-content, so on a narrow screen the track refuses to shrink and pushes
   out past its own container. That's what made the spotify players and photo
   cards sit 20px from the left but only 6px from the right. */
@media (max-width: 900px) {
  .music-grid > *, .block-inner > *, .releases > *,
  .social-row > * { min-width: 0; }
  /* .social-row isn't overflowing today, but "@romanellis7" is a single
     unbreakable 124px token in a 132px track — one character of slack. */
  /* Percent, not vw — 100vw counts the scrollbar. Kept in step with the ≤900px
     rule above (92%, not full-bleed): this block sits LATER in the file, so
     whatever it says wins, and leaving it at 100% silently reinstated the
     edge-to-edge photo on exactly the phones the change was aimed at. */
  .hero-photo, .hero-cutout { width: min(460px, 92%); }
}

@media (max-width: 640px) {
  /* grandma dave has no back, so she hides behind the frame and leans out.
     on a phone the panels were nearly full-width, leaving her nowhere to
     stand — she fell off the left edge. narrow the panels instead of moving
     her, so she still peeks out exactly as before, just on-screen.
     .portal is left-aligned inside .portal-wrap (which is sized by the caption
     below it), so it needs centring too or narrowing just pins it left. */
  .portal { width: min(460px, 74vw); margin-left: auto; margin-right: auto; }
  .portal-gdave { left: -16px; width: 48px; }
  .gate-panel { width: min(360px, 78vw); }
  .gate-gdave { left: -14px; width: 46px; }
  /* her -7deg tilt widens the rendered box ~9px to the left of the border box,
     so the panel has to sit further in than the raw offset suggests */

  /* ---- smaller + more delicate ----------------------------------------
     every clamp() in this file has a MINIMUM tuned for desktop. on a phone
     the vw term never wins, so the floor is what you always get — which is
     why the h1 landed at 72px and section titles at 30px on a 375px screen.
     these lower the floors. the outlined QuoteNote headings are sized in em
     (the wordmark is width:3.07em), so they scale with font-size for free. */
  /* caps match what the desktop clamps produce at 641px (12.5vw = 80.1, and the
     h2 floor is 30) so nothing jumps as you cross the breakpoint. phone sizes
     are unchanged — the vw term wins well below these caps. */
  .hero h1 { font-size: clamp(40px, 15vw, 80px); margin-left: -3px; }
  .hero-sub { font-size: 14.5px; margin-top: 16px; }
  h2 { font-size: clamp(21px, 6vw, 30px); margin-bottom: 12px; }

  .btn { font-size: 12.5px; padding: 11px 18px; }
  .btn-row { gap: 10px; margin-top: 24px; }
  /* .btn-sm sits EARLIER in this file than the .btn rule above, and both are
     one class deep — so without this the phone .btn silently overrode the small
     variant and blew the three release links out to 305px in a 280px row,
     pushing "youtube" 25px off-screen. Restate it, and let the row wrap as a
     backstop for longer link labels. */
  .btn-sm { font-size: 11.5px; padding: 8px 13px; }
  .release-links { flex-wrap: wrap; gap: 8px; }

  /* the header was wrapping to two rows and eating ~200px of a phone screen */
  .nav { padding: 11px 16px; row-gap: 7px; }
  .nav-brand { font-size: 17px; gap: 7px; }
  .nav-brand > svg:not(.qn) { width: 26px; height: 26px; }
  .nav-links { font-size: 11.5px; gap: 4px; letter-spacing: .04em; }
  .nav-links a { padding: 14px 7px; }        /* measured 39px at 11px — this is 45 */
  .nav-world { font-size: 11px; padding: 8px 10px; }

  /* the ticket strip was cramming onto three wrapped lines: side-by-side, the
     "book now" cta stole ~150px and left the date almost no room. drop the cta
     onto its own line so the text gets the full width. */
  .ticket-strip {
    display: grid; grid-template-columns: auto 1fr;
    gap: 6px 9px; align-items: start;
    padding: 10px 12px; margin-top: 16px; width: 100%;
  }
  .ts-live { font-size: 10px; padding: 4px 7px; align-self: start; }
  .ts-body { grid-column: 2; }
  .ts-body strong { font-size: 13px; line-height: 1.3; }
  .ts-body em { font-size: 10.5px; }
  .ts-cta { grid-column: 2; justify-self: start; font-size: 11.5px; }

  .show-list .show { gap: 10px; padding: 12px 18px 12px 12px; }   /* extra right so the tickets button clears the card edge */
  .s-date b { font-size: 19px; }
  .s-venue strong { font-size: 14px; }
  .s-venue em { font-size: 11px; }
  .s-book, .s-soon { font-size: 11px; padding: 9px 12px; }
  .s-book { padding: 7.5px 10.5px; }   /* docked for the 1.5px border, as above */

  .portal-label { font-size: 12px; padding: 7px 13px; }


  /* `* { padding: 0 }` strips the UA button padding, so this measured 12x24 */
  .gate-close {
    width: 44px; height: 44px; margin: -10px -10px 0 0;
    display: flex; align-items: center; justify-content: center;
  }

  /* delicacy: thinner rules and softer panel edges at small sizes */
  .block { border-top-color: rgba(255,255,255,.04); }
  .release, .social, .photo-card { border-color: rgba(255,255,255,.08); }
}

/* ---------- phones: fewer moving parts ------------------------------------------
   Everything below is about how MUCH is moving at once, not how it looks.
   The sky keeps its density; it just stops animating 109 things independently. */
@media (max-width: 900px) {
  /* ~109 stars each ran their own infinite opacity animation, inside a
     position:fixed layer sitting behind everything that scrolls — 109 separate
     things for the compositor to tick every frame.
     Most now sit still at their own random brightness (set inline in main.js) and
     roughly one in seven keeps twinkling, so the sky still moves at ~15% of the
     animation count. Deliberately NOT a fade on the .starfield container: that
     animates opacity on a position:fixed, inset:0 layer, which forces a
     viewport-sized render surface to be composited every frame, forever, and
     stops the page ever going idle. */
  .star, .tower .lite { animation: none; }
  .star.tw { animation: twinkle var(--tw, 5s) ease-in-out var(--td, 0s) infinite; }

  /* the header's travelling shimmer animates background-position — a repaint of the
     strip every frame, for a highlight that is 1px tall and 375px wide */
  .nav::before { animation: none; }

  /* both of these promote layers for scroll effects that are now desktop-only —
     on a phone they cost texture memory for nothing */
  .hero-photo { will-change: auto; }
  .photo-card img { will-change: auto; }

  /* the tiktok prism is desktop-only. main.js removes the other three faces from
     the DOM on phones, so flatten what's left into an ordinary looping clip —
     no perspective, no 3D context, no promoted layer. The edge shading on each
     face only ever made sense as the side of a turning box. */
  .tok-stage { perspective: none; }
  .tok-cube {
    transform: none; transition: none;
    transform-style: flat; will-change: auto;
  }
  .tok-face { transform: none; backface-visibility: visible; }
  .tok-face::after { display: none; }
}


/* five nav items only just miss fitting one row on a 320px phone — 286px of
   content against 288px available — and wrapping doubled the header to 192px,
   which undoes the slimming done earlier. Trim the type and padding a touch below
   420px so they stay on one line. Vertical padding is untouched, so the tap
   targets are still 44px+. */
@media (max-width: 420px) {
  .nav-links { font-size: 11px; gap: 2px; letter-spacing: .02em; }
  .nav-links a { padding: 14px 5px; }
}

/* sizes for the three instances. The hero one is the master and is deliberately
   larger than the old 38-56px so the letters are legible as they come round. */
.nstar.hero-star { display: block; width: clamp(58px, 6.4vw, 84px); margin: 0 auto 18px; }
.nstar.nav-star  { width: 25px; flex: 0 0 auto; }
.nstar.foot-star { display: block; width: 34px; margin: 0 auto; }

/* ---------- the two-faced north star ----------
   One face carries an R, the other an E, so a full revolution reveals both. Built
   as a real 3D pair rather than a cross-fade: the container preserves 3d and turns
   360, each face is backface-hidden, and the E face is pre-rotated 180 so it reads
   the right way round when it comes to the front.
   The letters are stroked, not filled — same weight and join as the star outline —
   and blue against the green so they read as a separate mark rather than part of
   the star. Sized in the star's own 64x84 viewBox so every instance scales as one. */
.nstar {
  position: relative; display: inline-block;
  transform-style: preserve-3d;
  animation: starSpin 9s linear infinite;
  transform-origin: 50% 50%;
}
.nstar-face { display: block; backface-visibility: hidden; }
.nstar-face + .nstar-face { position: absolute; inset: 0; transform: rotateY(180deg); }
.nstar svg { display: block; width: 100%; height: auto; overflow: visible; }
.nstar .np { fill: none; stroke: var(--accent); stroke-width: 2.6; stroke-linejoin: round; }
.nstar .nl {
  fill: none; stroke: #4dabf7; stroke-width: 2;
  stroke-linejoin: round; stroke-linecap: round;
  font-family: var(--font-disp); font-weight: 600; font-size: 21px;
  text-anchor: middle;
}
@media (prefers-reduced-motion: reduce) { .nstar { animation: none; } }

/* ---------- print ---------- */

/* printing: the outlined headings are white-on-dark, so give print real ink */
@media print {
  .starfield, .hero-photo, .hero-sky, .hero-grad, .scroll-cue, .book-rail,
  .gate, .intro, .portal-section, .shooter { display: none !important; }
  body { background: #fff !important; color: #000 !important; }
  .qn { fill: #000 !important; }
  .sr-only { position: static !important; width: auto !important; height: auto !important;
             clip: auto !important; margin: 0 !important; }
  .qn, .foot-star { display: none !important; }   /* show the real words instead */
  h1, h2 { font-family: var(--font-disp) !important; }
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 10px; }
}

/* ---------- spotify placeholders + cookie consent ----------
   A Spotify iframe sets third-party cookies the moment it loads, so under
   GDPR/PECR it can't load before consent. Each player is replaced by a
   placeholder built from cover art we already ship; consenting (or pressing
   "load player" on one) swaps the real iframe in. The "open in spotify" link
   is a plain link — no cookies — so the music is always reachable either way. */
.sp { position: relative; margin-top: 12px; }
/* A Spotify iframe paints white for the second or two it takes to boot, which on a
   dark site reads as three blank boxes flashing in. Two defences: the iframe sits
   on the panel colour so there is nothing white to see, and main.js keeps the
   styled placeholder up until the iframe's load event actually fires. */
.sp iframe { background: var(--bg-2); border-radius: 12px; display: block; }

/* the three tracks were listed twice in this section — once as players, once as
   cards right beside them. The cards are gone; this is the press shot that
   replaced them. */
.press-shot { border-radius: 10px; overflow: hidden; border: 1px solid var(--line); background: var(--bg-2); }
.press-shot img { width: 100%; height: auto; display: block; }
.sp:first-child { margin-top: 0; }
.sp-holder {
  display: grid; grid-template-columns: 62px 1fr auto;
  align-items: center; gap: 14px;
  min-height: 152px; padding: 14px 16px;
  border: 1px solid var(--line); border-radius: 12px;
  background: linear-gradient(180deg, #0e1326, #090d1a);
}
.sp-holder img { width: 62px; height: 62px; border-radius: 6px; object-fit: cover; }
.sp-meta { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.sp-meta strong { font-size: 15px; font-weight: 600; }
.sp-meta span {
  font-family: var(--font-mono); font-size: 11px; line-height: 1.5;
  color: var(--ink-dim);
}
.sp-load {
  font-family: var(--font-mono); font-size: 12px; font-weight: 700;
  color: #04120a; background: linear-gradient(180deg, #4ad169, #2f9e44);
  border: none; border-radius: 4px; padding: 11px 15px; cursor: pointer;
  white-space: nowrap; min-height: 44px;
  transition: transform .18s cubic-bezier(.2,.7,.3,1), box-shadow .2s;
}
.sp-load:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(64,192,87,.4); }
.sp-load:active { transform: translateY(1px) scale(.97); }
.sp-out {
  grid-column: 2 / -1;
  font-family: var(--font-mono); font-size: 11px; color: var(--accent);
}
.sp-out:hover { text-decoration: underline; }

.cc {
  position: fixed; left: 50%; bottom: 18px; z-index: 120;
  transform: translateX(-50%);
  width: min(680px, calc(100vw - 32px));
  display: flex; align-items: center; gap: 18px;
  padding: 14px 18px;
  background: rgba(11,15,29,.97);
  border: 1px solid var(--accent-dim); border-radius: 10px;
  box-shadow: 0 14px 40px rgba(0,0,0,.6);
  animation: ccIn .45s cubic-bezier(.2,.7,.25,1) .6s backwards;
}
@keyframes ccIn { from { opacity: 0; transform: translate(-50%, 24px); } }
.cc[hidden] { display: none; }
.cc-text {
  font-family: var(--font-mono); font-size: 11.5px; line-height: 1.65;
  color: var(--ink-dim); margin: 0;
}
.cc-text strong { color: var(--ink); font-weight: 700; }
.cc-text a { color: var(--accent); text-decoration: underline; }
.cc-btns { display: flex; gap: 8px; margin-left: auto; flex-shrink: 0; }
.cc .btn { font-size: 11.5px; padding: 10px 14px; min-height: 44px; white-space: nowrap; }
.cc-yes { border-color: var(--accent); color: var(--accent); }
.cc-yes:hover { background: rgba(64,192,87,.12); }

@media (max-width: 640px) {
  .cc { flex-direction: column; align-items: stretch; gap: 12px; bottom: 12px; padding: 14px; }
  .cc-btns { margin-left: 0; }
  .cc .btn { flex: 1; }
  .sp-holder { grid-template-columns: 52px 1fr; gap: 12px; padding: 12px 13px; min-height: 0; }
  .sp-holder img { width: 52px; height: 52px; }
  .sp-load { grid-column: 1 / -1; width: 100%; }
  .sp-out { grid-column: 1 / -1; }
}

/* On a phone the notice is a bottom bar, and the thing directly under it is the
   ticket strip — the one commercial CTA on the page. Keep it to two lines with
   the button beside the text rather than under it, so it covers as little of
   that as possible. */
@media (max-width: 640px) {
  .cc { padding: 10px 12px; gap: 10px; align-items: center; }
  .cc-text { font-size: 11.5px; line-height: 1.45; }
  .cc-btns { margin-left: auto; }
  .cc .btn { font-size: 11px; padding: 9px 12px; min-height: 40px; }
}
