/* ============================================================================
   app-shell.css — the two pieces of chrome that must be IDENTICAL on
   index.html, extra.html and pro.html: the desktop Home/Extra/Pro switch and
   the phone bottom bar's travelling indicator.
   Loaded by all three pages. Deliberately the first shared stylesheet on this
   site: everything else is duplicated per page, and the whole point of these
   two controls is that a visitor meets the same thing wherever they are.

   Self-sufficient by design. Every token is read with a fallback
   (var(--ink,#E8EFE9)) because the three pages do not define the same set —
   extra.html and pro.html carry their own trimmed palettes.
   ============================================================================ */

:root{
  --as-ink:#E8EFE9;
  --as-line:rgba(232,239,233,.13);
  --as-panel:rgba(232,239,233,.05);
  --as-mono:ui-monospace,"Cascadia Mono","SF Mono",Consolas,"Liberation Mono",monospace;
}

/* The three legacy mode buttons stay in the DOM so index.html's existing JS keeps its
   handles, but the switch is what the visitor sees. */
.asRetired{display:none !important;}

/* ================= ONE PLACE ON ALL THREE PAGES (2026-09-08) =================
   The switch was mounted wherever each page had room — beside index's wordmark, in extra's
   left group, in pro's right-hand cluster — so the one control whose whole job is to be
   identical everywhere appeared in three different spots, and moving between the pages made
   it jump. Pinned to the top centre of the viewport instead, which is the only position all
   three headers share.

   Fixed rather than centred inside each header, because the three headers are three
   different flex layouts and centring inside each would drift with their contents; fixed is
   the same sixteen pixels from the top on every page, always.

   EVERY width, phones included (2026-09-08, second pass). The first cut left the phone
   alone, and the phone turned out to have exactly the problem this rule exists to fix: the
   switch sat right of the wordmark on home, right of the wordmark on extra, and on a row of
   its own at the left on pro — three places again. It is the same control in the same place
   now, and each header's own left- and right-hand items keep out of the middle. */
#modeSwitch{
  position:fixed;top:calc(10px + env(safe-area-inset-top));left:50%;transform:translateX(-50%);
  z-index:85;                        /* over content, under index's dialogs (9999) and pro's tooltip (90) */
}
@media (min-width:1001px){ #modeSwitch{top:16px;} }
/* It floats over whatever scrolls under it, so it carries its own ground rather than
   borrowing the header's. */
#modeSwitch .asSwitch{
  background:rgba(20,27,23,.72);
  -webkit-backdrop-filter:saturate(160%) blur(14px);
  backdrop-filter:saturate(160%) blur(14px);
  box-shadow:0 6px 26px rgba(0,0,0,.34), inset 0 1px 0 rgba(255,255,255,.14);
}
@media (max-width:1000px){
  /* Smaller segments, so the centred pill leaves each header room on both sides. At 375px
     the budget is exact: the ring on the left, this in the middle, and index's live-price
     pill on the right have to share 351px between them. */
  #modeSwitch .asSeg{font-size:11.5px;padding:7px 11px;}
}
/* The smallest phones still in use (320-400px). One more notch off the switch, and index's
   price pill drops its unit at the same breakpoint, which together buy back ~45px. */
@media (max-width:400px){
  #modeSwitch .asSeg{font-size:11px;padding:6px 10px;}
  .mNowUnit{display:none;}
  /* The ring alone on a phone. All three pages set the same wordmark as an <img> (369x57,
     with the ring inside its first ~57 units) and at full width it reached into the middle
     of the bar on every one of them. A square box with object-fit:cover scales the file to
     the box HEIGHT and object-position:left crops the rest away, so this shows exactly the
     ring — no second asset, no markup change. !important because extra.html and pro.html
     carry their heights inline. */
  .brand img,.brandLogo{
    width:22px !important;height:22px !important;
    object-fit:cover;object-position:left center;flex:0 0 auto;
  }
}

/* ============================ THE MODE SWITCH ============================
   Three destinations, one control, same place on every page. It replaces the
   old arrangement of three separate buttons whose labels changed meaning by
   context (#extraBtn / #modeBtn / #goProBtn) — where "Pro" could mean either
   "go to the pro product" or "leave pro", depending on where you already were.
   A segmented control cannot have that problem: the current page is the lit
   segment, and every segment is a destination. */
.asSwitch{
  position:relative;display:inline-flex;align-items:stretch;
  padding:3px;border-radius:100px;
  background:var(--hero-panel,var(--as-panel));
  border:1px solid var(--hero-line,var(--as-line));
  /* Sits above the moving pill's glow so the labels stay legible. */
  isolation:isolate;
}
.asSwitch__pill{
  position:absolute;top:3px;left:0;height:calc(100% - 6px);
  border-radius:100px;pointer-events:none;z-index:0;
  background:rgba(232,239,233,.13);
  border:1px solid rgba(232,239,233,.26);
  box-shadow:0 0 12px rgba(232,239,233,.10);
}
.asSwitch__pill.asNoTrans{transition:none;}
.asSeg{
  position:relative;z-index:1;
  background:none;border:0;cursor:pointer;font-family:inherit;
  font-size:12.5px;font-weight:600;letter-spacing:.01em;line-height:1;
  padding:7px 14px;border-radius:100px;white-space:nowrap;
  color:rgba(232,239,233,.55);
  transition:color .18s ease;
  -webkit-tap-highlight-color:transparent;
}
.asSeg:hover{color:rgba(232,239,233,.85);}
.asSeg[aria-current="page"]{color:var(--ink,var(--as-ink));}
.asSeg:focus-visible{outline:2px solid var(--ink,var(--as-ink));outline-offset:2px;border-radius:100px;}
/* A locked destination still shows — knowing Pro exists is the point — but says so. */
.asSeg[data-locked="1"]::after{
  content:"";display:inline-block;width:5px;height:5px;margin-left:6px;
  border-radius:50%;background:var(--mid-lite,#EFBE58);vertical-align:middle;opacity:.75;
}
@media (max-width:760px){
  /* Bigger tap targets on a touch screen. #modeSwitch narrows its own segments again just
     above — it is a floating pill in a shared header row, not a control with a row to
     itself — and an id beats a class, so that rule wins where it applies. */
  .asSeg{font-size:13px;padding:9px 16px;}
}

/* ===================== THE BOTTOM BAR'S TRAVELLING INDICATOR =====================
   The capsule that marks the active tab. It does not slide as a rigid shape: on
   the way over it STRETCHES to span the gap it is crossing and contracts again
   at the destination, which is what makes the movement read as one connected
   gesture rather than a box being teleported.

   NEUTRAL ON PURPOSE. The reference this was taken from gives every tab its own
   hue; this bar cannot. Colour on this site means PRICE — cheap, mid, dear — and
   a red selection ring on an expensive evening would read as an alarm rather
   than as "you are here". The motion is copied; the colour language is not.

   The width is animated rather than scaleX'd: scaling a bordered capsule
   horizontally thins its vertical strokes and smears the round ends, and at this
   size that is visible. It is one absolutely-positioned element, so an animated
   width reflows nothing but itself. */
/* nav.mTabs, not .mTabs: index.html sets `position:static` on the bar inside its own
   ≤760px block (deliberately — the bar is in flow, not over the scroller), that rule is
   later in the cascade, and a media query adds no specificity. The tag selector wins by
   one without touching what the static was there to guarantee: `relative` is still in
   flow, it only gives the absolutely-positioned capsule a containing block to measure
   against. Without it the capsule resolves against the viewport and never moves. */
nav.mTabs{position:relative;}
.mTabInd{
  position:absolute;top:5px;left:0;z-index:0;
  width:46px;height:32px;border-radius:100px;
  border:1.6px solid rgba(232,239,233,.95);
  background:rgba(232,239,233,.06);
  /* The reference is a NEON ring — the glow is most of what you see, not a hint at the
     edge. The first cut had it at .22 and it read as a faint outline. Three layers: a
     wide soft bloom, a tight bright halo, and an inner fill so the ring has body. */
  box-shadow:0 0 22px rgba(232,239,233,.42),
             0 0 7px  rgba(232,239,233,.60),
             inset 0 0 12px rgba(232,239,233,.16);
  pointer-events:none;
  /* Hidden until the first measurement lands, so it can never flash at x=0. */
  opacity:0;
}
.mTabInd.asOn{opacity:1;transition:opacity .2s ease;}
/* The tab's own content must sit above the capsule, and the LIVE tab's glyph and label
   glow with it — in the reference the label lights up in the ring's colour, and a lit
   ring over an unlit label reads as two unrelated things. */
.mTabs .mTab{position:relative;z-index:1;}
.mTabs .mTab[aria-current="true"]{
  text-shadow:0 0 10px rgba(232,239,233,.55);
}
.mTabs .mTab[aria-current="true"] svg{
  filter:drop-shadow(0 0 5px rgba(232,239,233,.65));
}

@media (prefers-reduced-motion:reduce){
  /* The indicator still moves — it has to, it is the only thing saying which tab is
     live — but it goes straight there with no stretch and no travel. */
  .mTabInd{transition:none;}
}

/* ============================ REFRACTIVE GLASS ============================
   From the technique in the reference clip: blur() only AVERAGES the pixels
   behind a panel, which is why a blurred div still reads as a flat rectangle.
   Real glass BENDS what is behind it, so the convincing version displaces the
   backdrop through noise — feTurbulence into feDisplacementMap — and then adds
   a one-pixel lit edge along the top, which is what sells the thickness.

   THREE DELIBERATE LIMITS ON WHERE THIS IS USED, all of them load-bearing:

   1. Only where something INTERESTING sits behind it. Refraction over a flat
      dark ground is invisible and still costs a full filtered repaint of the
      backdrop every frame. On this site that means the phone's cards and the
      tab bar, which sit over the starfield and the state gradient — not the
      desktop's full-screen bands, which are the most expensive possible target
      and have nothing behind them to bend.
   2. The blur is the BASE declaration and the refraction is layered on top in
      @supports. url() inside backdrop-filter is a Chromium feature today;
      Safari — which is most of this site's phone traffic — does not honour it.
      Written the other way round, an unsupported url() would invalidate the
      whole declaration and Safari would lose the frosting altogether.
   3. Gated behind .asFx on <html>, which app-shell.js sets only when the
      visitor has not asked for reduced motion. No JS, no refraction, still
      frosted.

   Note this REOPENS a decision from the 2026-08-18 design-system pass, which
   replaced every backdrop-filter panel with flat --card + --rule. That removal
   was right for the desktop bands and stays. What comes back is narrower: the
   phone surfaces that already went translucent on purpose so the background
   could show through. */
/* THE BLUR IS THE MISTAKE TO AVOID. The first cut ran blur(14px) over a 52%-opaque fill and
   the panels read as flat dark boxes — which is the whole trap this technique exists to
   escape. Glass is not frosting: refraction only shows if there is still DETAIL behind it to
   bend, and fourteen pixels of blur destroys the fine structure (the stars, the field's
   gradients) that carries it. The reference uses blur(3px) for exactly this reason.
   So: much less blur, much more transparency, and let the displacement do the work. */
.asGlass{
  background:rgba(20,27,23,.34);
  -webkit-backdrop-filter:saturate(150%) blur(4px);
  backdrop-filter:saturate(150%) blur(4px);
  /* The lit edge. One pixel, white, low alpha, top only — the thing that stops
     it being a div. Plus a soft drop, which is what lifts a pane off its background. */
  box-shadow:inset 0 1px 0 rgba(255,255,255,.34), 0 10px 40px rgba(0,0,0,.26);
}
@supports ((backdrop-filter:url(#asGlassLG)) or (-webkit-backdrop-filter:url(#asGlassLG))){
  html.asFx .asGlass{
    -webkit-backdrop-filter:url(#asGlassLG) saturate(150%) blur(2px);
    backdrop-filter:url(#asGlassLG) saturate(150%) blur(2px);
  }
}
/* A quieter grade for large surfaces: same lit edge, weaker displacement cost. */
.asGlass--calm{
  -webkit-backdrop-filter:saturate(140%) blur(10px);
  backdrop-filter:saturate(140%) blur(10px);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.22);
}

/* ======================= THE AMBIENT FIELD (portable) =======================
   The starfield and the three drifting colour fields, lifted out of index.html
   so extra.html and pro.html can carry the same background. Deliberately a NEW
   class (.asAmbient, not .ambient): index.html's original is entangled with its
   scroll gauge, its story bands and a pile of z-index rules that took several
   passes to settle, and it works. Nothing is gained by making it share this.

   THE COLOUR CHAIN IS THE WHOLE TRICK. The three pages do not share a palette:
   index has --cheap/--mid/--high, pro has the same NAMES holding index's -lite
   values, and extra calls them --green/--amber/--coral. A blob filled from an
   undefined custom property is an invalid declaration and is dropped silently,
   so a naive port renders three invisible divs. Each fill therefore walks all
   three vocabularies before falling back to a literal. */
.asAmbient{
  --as-cheap:var(--cheap,var(--green,#35A46E));
  --as-mid:var(--mid,var(--amber,#C99732));
  --as-high:var(--high,var(--coral,#D65B40));
  /* THE STATE COLOUR, resolved here rather than borrowed from the page. index.html rebinds
     --accent to the current price state on every render; extra.html never defines it at all
     and pro.html pins it to a fixed green. So anything in this file that reached for
     var(--accent) was green on an expensive evening on two pages out of three — which is
     exactly what the cursor light did. Its default is the MID hue, not cheap: with no
     data-state yet (a slow or failed price fetch) amber is the honest neutral, and a page
     that opens green and then turns red has already told the reader something untrue. */
  --as-accent:var(--as-mid);
  position:fixed;inset:-20vmax;z-index:0;pointer-events:none;overflow:hidden;
  /* saturate() under 1 is the "different tone" — the fields read as weather
     behind the page rather than as a second, larger copy of the price colour
     competing with the verdict that is supposed to carry it. */
  filter:hue-rotate(var(--amb-hue,0deg)) saturate(var(--as-amb-sat,.78));
  transition:filter 1.4s cubic-bezier(.22,1,.36,1);
}
.asAmbient i{
  position:absolute;border-radius:50%;
  width:70vmax;height:70vmax;
  filter:blur(90px);mix-blend-mode:screen;
  /* --as-amb-k is the per-page dimmer. See the [data-app-page] rules below. */
  opacity:calc(var(--as-ambO,.28) * var(--as-amb-k,1));
  will-change:transform;
  transform:translate3d(calc((var(--amb-x,.5) - .5) * var(--lean,6vmax)),calc((var(--amb-y,.5) - .5) * var(--lean,6vmax)),0);
  transition:transform 2.2s cubic-bezier(.22,1,.36,1),opacity 1.4s cubic-bezier(.22,1,.36,1);
}
.asAmbient .a1{left:-10vmax;top:5vmax;--lean:9vmax;
  background:radial-gradient(closest-side,var(--as-high) 0%,transparent 70%);animation:asDrift1 38s ease-in-out infinite alternate;}
.asAmbient .a2{right:-14vmax;top:22vmax;--lean:-7vmax;
  background:radial-gradient(closest-side,var(--as-mid) 0%,transparent 70%);animation:asDrift2 47s ease-in-out infinite alternate;}
.asAmbient .a3{left:20vmax;bottom:-18vmax;--lean:5vmax;
  background:radial-gradient(closest-side,var(--as-cheap) 0%,transparent 70%);animation:asDrift3 55s ease-in-out infinite alternate;}

/* THE STATE MIX. The blob HUES never change; what leans with the price is the
   opacity. The current state's own field leads and the other two drop to a
   trace, so the background says something instead of showing all three states
   at once. Both target pages set body[data-state] themselves (extra in
   renderHero, pro in renderNow) — but BOTH can also fail to set it at all when
   the price feed is empty, so the .28 fallback above has to be a sane neutral
   on its own rather than a value that only makes sense once a state arrives. */
body[data-state="high"]  .asAmbient .a1{--as-ambO:.34;}
body[data-state="high"]  .asAmbient .a2{--as-ambO:.13;}
body[data-state="high"]  .asAmbient .a3{--as-ambO:.07;}
body[data-state="mid"]   .asAmbient .a1{--as-ambO:.10;}
body[data-state="mid"]   .asAmbient .a2{--as-ambO:.36;}
body[data-state="mid"]   .asAmbient .a3{--as-ambO:.07;}
body[data-state="cheap"] .asAmbient .a1{--as-ambO:.07;}
body[data-state="cheap"] .asAmbient .a2{--as-ambO:.12;}
body[data-state="cheap"] .asAmbient .a3{--as-ambO:.34;}
body[data-state="cheap"] .asAmbient{--as-accent:var(--as-cheap);}
body[data-state="mid"]   .asAmbient{--as-accent:var(--as-mid);}
body[data-state="high"]  .asAmbient{--as-accent:var(--as-high);}

/* THE GROUND LEANS TOO. It was pinned to one green-tinted near-black on both pages, so an
   expensive evening still sat on a green floor — the fields leaned red above a ground that
   did not. These are index.html's own three state grounds, which is where the page under the
   fields gets its warmth from on the home page. The default (no data-state) stays neutral. */
html[data-app-page="extra"] body,
html[data-app-page="pro"] body{transition:background-color 1.2s ease;}
html[data-app-page="extra"] body[data-state="cheap"],
html[data-app-page="pro"] body[data-state="cheap"]{background-color:#0D1512;}
html[data-app-page="extra"] body[data-state="mid"],
html[data-app-page="pro"] body[data-state="mid"]{background-color:#151007;}
html[data-app-page="extra"] body[data-state="high"],
html[data-app-page="pro"] body[data-state="high"]{background-color:#170B07;}

/* Drift with `translate`, never margins. index.html learned this the expensive
   way: animating margins re-lays-out every frame of a 38-second loop and each
   one is recorded as a layout shift — measured at CLS 0.418 and still climbing,
   against a 0.1 threshold. It was not late content, it was the background
   breathing. `translate` composites and is excluded from CLS by definition, and
   it does not collide with the `transform` already carrying the cursor lean. */
@keyframes asDrift1{from{translate:0 0}to{translate:12vmax 16vmax}}
@keyframes asDrift2{from{translate:0 0}to{translate:-10vmax -14vmax}}
@keyframes asDrift3{from{translate:0 0}to{translate:-16vmax -12vmax}}

.asAmbient .asStars{
  position:absolute;inset:0;width:100%;height:100%;
  opacity:calc(.55 * var(--as-amb-k,1));
  transform:translate3d(calc((var(--amb-x,.5) - .5) * -1.6vmax),calc((var(--amb-y,.5) - .5) * -1.6vmax),0);
  transition:transform 1.6s cubic-bezier(.22,1,.36,1);
  animation:none;filter:none;border-radius:0;margin:0;
}

/* ================= THE SKY MOVES WITH THE WIND (2026-09-08) =================
   The starfield drifts in the direction the wind is actually blowing over Lithuania, at a
   speed set by how hard it is blowing. On a still night it is almost motionless; in a gale
   the whole sky streams across the page. It is the same wind that decides how much of the
   country's electricity is free that day, so the background is quietly reporting the thing
   the site is about.

   HOW IT LOOPS SEAMLESSLY. The stars are a REPEATING tile (drawn once by app-shell.js, with
   the stars near each edge repeated on the opposite side so the seams disappear). The layer
   is rotated to the wind bearing and then translated along its own local Y by exactly one
   tile height — and because the tile repeats, the end of the cycle is pixel-identical to the
   start, so the loop has no jump. `rotate` and `translate` are used as INDEPENDENT transform
   properties, so the animation touches only the translation and the rotation survives it.

   Both are compositor properties: the field costs nothing per frame, unlike animating
   background-position, which repaints a full-screen layer every frame. */
/* TWO elements, and the split is the whole reason this works. The first attempt put the
   rotation and the drift on ONE element sized `inset:-45%`: percentage insets resolve against
   the parent's width for left/right and its HEIGHT for top/bottom, so on a non-square parent
   the box came out lopsided, and rotating it about its own centre threw it up and to the left
   — measured at (-1396,-883) with barely a sliver still over the viewport. There were stars
   the whole time; almost none of them were on screen.

   Now the outer element only ROTATES and the inner one only DRIFTS. Sized in vmax from its
   own centre rather than as a percentage of an already-oversized parent, so its geometry does
   not depend on the ambient layer's aspect ratio at all. 130vmax is comfortably past the
   (width+height)/1.41 a rotated square needs to cover the viewport at any bearing. */
/* THE RESETS ARE LOAD-BEARING. `.asAmbient i` styles the three colour fields — 70vmax
   circles under a 90px blur, screen-blended — and it matches ANY element in this layer that
   the more specific rules do not fully override. The first version of these two used <i>
   elements and set only `inset`, so both inherited the blob's explicit width/height (which
   beats inset), its border-radius, its mix-blend-mode and, fatally, its blur(90px): the star
   texture was being smeared across ninety pixels inside a circle. There were stars; they
   were unreadable. They are <div>s now AND every blob property is stated back to normal, so
   neither this rule nor a future edit to the blobs can reach them again. */
/* Selectors are DELIBERATELY unqualified by the ambient class: index.html keeps its own
   original `.ambient` layer (entangled with its scroll gauge and story bands, and working),
   while extra and pro use the shared `.asAmbient`. The wind sky has to be able to live in
   either, so it is matched on its own classes and injected as <div>s — which is also what
   keeps the blob rules (`.ambient i`, `.asAmbient i`) from reaching it. */
.asWindRot,
.asWindTex{
  filter:none;border-radius:0;mix-blend-mode:normal;
  transition:none;transform:none;will-change:auto;
}
.asWindRot{
  position:absolute;top:50%;left:50%;
  width:130vmax;height:130vmax;margin:-65vmax 0 0 -65vmax;
  rotate:var(--as-wind-rot,200deg);
  animation:none;
  pointer-events:none;
}
.asWindTex{
  position:absolute;top:0;left:0;
  width:100%;height:100%;margin:0;
  background-image:var(--as-star-tex);
  background-repeat:repeat;
  background-size:var(--as-star-tile,600px) var(--as-star-tile,600px);
  opacity:calc(.62 * var(--as-amb-k,1));
  animation:asWindDrift var(--as-wind-dur,90s) linear infinite;
}
/* index.html's own starfield canvas, retired once the drifting one is up. */
.asRetiredStars{display:none !important;}
@keyframes asWindDrift{
  from{translate:0 0;}
  to{translate:0 var(--as-star-tile,600px);}
}
/* The canvas is retired once the tiled field is up — app-shell.js hides it. Kept in the DOM
   rather than removed so a page that somehow cannot build a texture still has its sky. */
@media (prefers-reduced-motion:reduce){
  /* Still a sky, just not a moving one. */
  .asAmbient .asWindTex{animation:none;}
}
.asAmbient .asCursor{
  position:absolute;left:0;top:0;width:44vmax;height:44vmax;border-radius:50%;
  margin:-22vmax 0 0 -22vmax;
  background:radial-gradient(closest-side,var(--as-accent) 0%,transparent 70%);
  opacity:calc(.15 * var(--as-amb-k,1));filter:blur(36px);mix-blend-mode:screen;
  /* The pointer is in VIEWPORT pixels but this element's origin is .asAmbient's
     corner, and the layer is inset -20vmax so the blur can bleed off-screen.
     Without the +20vmax the light sits a fifth of a screen up and to the left of
     the hand, which reads as a lamp following at a distance. */
  transform:translate3d(calc(var(--cur-x,50vw) + 20vmax),calc(var(--cur-y,50vh) + 20vmax),0);
  transition:transform .22s cubic-bezier(.2,.8,.3,1),opacity .6s ease;
  animation:none;
}
.asAmbient .asCursor.idle{opacity:0;}

/* PER-PAGE STRENGTH. extra.html is a television dashboard read across a room and
   its own ground already carries a state-tinted radial glow; at full strength the
   fields fought it and washed the figures. Dimmed hard there, per the brief.
   app-shell.js writes data-app-page on <html>. */
/* First cut was .42 / saturate .62 on extra and it was INVISIBLE — a 12%-opacity blob
   blurred 90px, desaturated, over a red-brown ground is a rumour, not a background. The
   brief was "faded, not dominant", not "gone". Extra now sits at .72 against a neutral
   ground (below); pro at full strength like the home page. */
html[data-app-page="extra"] .asAmbient{--as-amb-k:.72;--as-amb-sat:.85;}
html[data-app-page="pro"]   .asAmbient{--as-amb-k:1;--as-amb-sat:.9;}

/* THE GROUND GOES NEUTRAL, THE FIELDS CARRY THE COLOUR. This is how the home page works
   and it is the whole reason the fields exist. extra.html did the opposite: its :root
   ground is the red colourway and body[data-state] repaints the ENTIRE ground family red,
   amber or green — and when the price fetch is slow or fails there is no data-state at
   all, so the page just sits red. That is the "always red" the owner saw. With the ground
   pinned near-black the fields are the only thing tinting, they cannot be red without a
   red price, and the stars finally have something dark to sit on. pro.html's two body
   glows are dropped for the same reason — they were a second, fainter copy of this. */
html[data-app-page="extra"] body{background:#0D1210;}
html[data-app-page="extra"] .kiosk{background:none;}
html[data-app-page="pro"] body{background:var(--bg,#0C1310);}

/* Extra's widgets go glass over the field, as pro's did. .card on extra is an opaque
   state-tinted panel; over a living background an opaque panel is a hole in it. Same
   recipe as .asGlass, written here at (0,1,1) so it clears extra's own .card rule. */
html[data-app-page="extra"] .card,
html[data-app-page="extra"] .zCard{
  background:rgba(18,24,21,.30);
  -webkit-backdrop-filter:saturate(150%) blur(4px);
  backdrop-filter:saturate(150%) blur(4px);
  /* The page's own brown panel border read as a card edge, which is the one thing a pane of
     glass does not have. A pale hairline reads as the lit rim of the pane instead. */
  border:1px solid rgba(246,239,231,.13);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.32), 0 10px 40px rgba(0,0,0,.26);
}
@supports ((backdrop-filter:url(#asGlassLG)) or (-webkit-backdrop-filter:url(#asGlassLG))){
  /* .zCard was left out of the refraction the first time round — the six neighbour-price
     tiles were frosted while everything around them bent the light. */
  html.asFx[data-app-page="extra"] .card,
  html.asFx[data-app-page="extra"] .zCard{
    -webkit-backdrop-filter:url(#asGlassLG) saturate(150%) blur(2px);
    backdrop-filter:url(#asGlassLG) saturate(150%) blur(2px);
  }
}

/* Content must be lifted above the layer or the fixed ambient paints over it —
   neither page has a root-level stacking context of its own. */
html[data-app-page="extra"] .kiosk,
html[data-app-page="extra"] section.ten,
html[data-app-page="pro"] .wrap{position:relative;z-index:1;}

@media (prefers-reduced-motion:reduce){
  .asAmbient i{animation:none;transition:none;}
  .asAmbient{transition:none;}
  .asAmbient .asCursor{display:none;}
}

/* ===================== THE PHONE APP SHELL (portable) =====================
   The five-screen phone app: a fixed compact page, a scrolling middle, and the
   bottom tab bar. Lifted out of index.html so extra.html and pro.html get the
   SAME bar rather than a third and fourth hand-copy — the divergence this site
   keeps paying for (four independent forecast panels, and a fix reaches one).

   The mechanism is one rule per tab that HIDES every tagged section not
   belonging to it. Nothing is ever forced visible, so [hidden], .hidden and every
   JS reveal keep their exact current meaning inside a screen — which matters
   enormously here, because extra.html gates five of its panels on the hidden
   attribute and pro.html gates two more. */
.mTabs{display:none;}
.asShellOnly{display:none;}

@media (max-width:760px){
  html[data-app-page="extra"] body,
  html[data-app-page="pro"] body{
    height:100dvh;overflow:hidden;display:flex;flex-direction:column;margin:0;
  }
  /* The page stops scrolling and an inner element starts, so the bar can be an
     ordinary in-flow sibling instead of a fixed overlay that content slides under. */
  .asScroll{
    flex:1 1 auto;min-height:0;overflow-y:auto;
    overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;
  }
  .asShellOnly{display:revert;}

  body[data-mtab="now"]      [data-mscreen]:not([data-mscreen~="now"]),
  body[data-mtab="chart"]    [data-mscreen]:not([data-mscreen~="chart"]),
  body[data-mtab="forecast"] [data-mscreen]:not([data-mscreen~="forecast"]),
  body[data-mtab="market"]   [data-mscreen]:not([data-mscreen~="market"]),
  body[data-mtab="more"]     [data-mscreen]:not([data-mscreen~="more"]){display:none !important;}

  /* --- the bar --- */
  .mTabs{
    display:grid;grid-template-columns:repeat(5,1fr);
    position:static;flex:0 0 auto;z-index:70;
    padding:6px 4px calc(6px + env(safe-area-inset-bottom));
    border-top:1px solid var(--mtabLine,rgba(255,255,255,.09));
    transition:background .9s ease,border-color .9s ease;
  }
  /* Element+class (0,1,1) so it outranks .asGlass (0,1,0): the bar keeps its own
     state-tinted ground and .asGlass contributes only the blur and the lit edge.
     Written the other way round, every page's bar would be the same fixed
     green-grey and would read as wrong over extra.html's brown ground. */
  nav.mTabs{background:var(--mtabBg,rgba(13,18,16,.82));}
  /* The bar leans with the price like the rest of the page, but only just: it sits
     under the thumb for a whole session and a saturated bar would compete with the
     verdict, which is the thing that is supposed to carry colour. A lean, not a paint. */
  body[data-state="cheap"] .mTabs{--mtabBg:rgba(11,23,17,.86);--mtabLine:rgba(95,211,148,.20);}
  body[data-state="mid"]   .mTabs{--mtabBg:rgba(24,19,8,.86); --mtabLine:rgba(239,190,88,.22);}
  body[data-state="high"]  .mTabs{--mtabBg:rgba(26,14,10,.86);--mtabLine:rgba(245,132,101,.22);}

  .mTab{
    display:flex;flex-direction:column;align-items:center;gap:3px;
    background:none;border:0;cursor:pointer;
    font-family:inherit;font-size:10.5px;font-weight:600;letter-spacing:.02em;
    color:var(--ink-faint,rgba(232,239,233,.45));
    padding:5px 2px 4px;border-radius:12px;min-height:48px;
    -webkit-tap-highlight-color:transparent;
    transition:color .18s ease;
  }
  .mTab svg{width:23px;height:23px;display:block;}
  /* Active = ink, never the accent: the accent tracks the price state, and a red
     "selected" glyph on an expensive evening reads as an alert, not a selection. */
  .mTab[aria-current="true"]{color:var(--ink,var(--as-ink));}
  .mTab:focus-visible{outline:2px solid var(--cheap-lite,#5FD394);outline-offset:2px;}

  /* --- per-page release of the desktop shape --- */
  /* THE blocker on extra.html: .kiosk declares min-width:1500px and nothing ever
     resets it, so a 375px phone renders a 1500px box and scrolls sideways. Until
     this is undone no other responsive work on that page is even visible. */
  html[data-app-page="extra"] .kiosk{
    min-width:0;width:auto;height:auto;min-height:0;overflow:visible;display:block;
  }
  html[data-app-page="extra"] .wrap{padding:0 12px;}
  html[data-app-page="extra"] .grid{display:block;}
  /* The columns group cards that belong to DIFFERENT screens, so they must stop
     being boxes and let their children be tagged individually. */
  html[data-app-page="extra"] .col{display:contents;}
  html[data-app-page="extra"] .tenIn{padding:0 12px;}
  html[data-app-page="extra"] .top{flex-wrap:wrap;row-gap:8px;}

  html[data-app-page="pro"] .wrap{padding:0 12px 18px;}
  html[data-app-page="pro"] .g2,
  html[data-app-page="pro"] .g3{grid-template-columns:1fr;}
  html[data-app-page="pro"] .top{flex-wrap:wrap;row-gap:8px;}
  /* The two vh-derived chart heights are the ones that misbehave first on a phone. */
  html[data-app-page="pro"] #tenLead svg{height:clamp(170px,26vh,260px);}
  /* pro.html is really two pages: the entitled dashboard (#app) and the sign-in gate
     (#gate) shown when the payload is not entitled. The gate has no tagged sections
     at all, so every tab would show the same single screen — a five-tab bar that
     visibly does nothing. Hide it there. If :has() is unsupported the rule is simply
     dropped and the bar reappears, which is the behaviour without this rule anyway. */
  html[data-app-page="pro"] body:has(#gate:not(.hidden)) .mTabs{display:none;}
}

/* ======================= WAITING IS A DESIGNED MOMENT =======================
   Reserve the space BEFORE it fills. Late content that arrives into a collapsed
   box does not just look untidy — it moves whatever the reader was about to
   touch, and on a phone that means the tap lands on the wrong thing.

   These are the containers measured as jumping on extra.html: an empty grid, an
   empty bar row and a one-line paragraph that becomes eight. Each is given the
   height its filled state actually occupies, so the arrival is a paint, not a
   reflow. The values are the rendered heights, not guesses.

   Note what is NOT done here: nothing is given a spinner. A spinner says only
   "something is happening"; a held space that then fills says the same thing and
   costs no attention. index.html's #genCard already had this right — it keeps its
   content laid out under `visibility:hidden` rather than removing it — and this
   generalises that instinct to the places that lacked it. */
/* Measured, not guessed: #zGrid fills to 100px and #genCols to 116px, so those are
   the heights held. An earlier pass reserved 132px for #zGrid and made it WORSE —
   the box then shrank by 32px when the data landed, trading a grow for a collapse.
   Under-reserving slightly is the safer error of the two; over-reserving guarantees
   a shift in the opposite direction. */
html[data-app-page="extra"] #zGrid:empty{min-height:100px;}
html[data-app-page="extra"] #genCols:empty{min-height:116px;}
/* #flowRows is deliberately NOT reserved. Its card is `hidden` until flows.php
   answers, so the row box is not what moves — the whole card appearing is, and a
   min-height on its contents cannot fix that. Doing it properly means holding the
   card's own space (or keeping it visible with a real empty state), which needs the
   flows endpoint stubbed to measure honestly. Left alone rather than shipped on a
   guessed number. */
/* One line while empty is what makes a five-to-eight line paragraph shove the
   card below it down the column. Hold a realistic block instead. */
html[data-app-page="extra"] .insBody{min-height:5.2em;}

/* The shimmer, for the one case a held space is not enough: a surface that will
   stay empty for seconds and needs to say it is working rather than broken.
   Opt-in per element, and it stops dead under reduced-motion — a permanently
   sweeping gradient is exactly the kind of ambient movement that setting exists
   to switch off. */
.asSkel{
  position:relative;overflow:hidden;
  background:rgba(232,239,233,.045);border-radius:8px;
}
.asSkel::after{
  content:"";position:absolute;inset:0;transform:translateX(-100%);
  background:linear-gradient(90deg,transparent 0%,rgba(232,239,233,.08) 50%,transparent 100%);
  animation:asSkelSweep 1.35s ease-in-out infinite;
}
@keyframes asSkelSweep{to{transform:translateX(100%);}}
/* The shapes skel() writes. Each mirrors the widget it stands in for: a zone card is a
   card with two lines, a chart is a row of bars of varying height, a forecast list is
   stacked rows, prose is lines that shorten on the last one — so the reader recognises
   the layout before a single number has arrived. */
.asSkelRow{display:block;width:100%;margin:6px 0;}
.asSkelCard{min-height:54px;padding:10px 12px;display:flex;flex-direction:column;gap:7px;justify-content:center;border-radius:10px;}
.asSkelLine{display:block;height:10px;border-radius:5px;background:rgba(232,239,233,.09);margin:4px 0;}
.asSkelLine.asSkelBig{height:16px;}
.asSkelBar{display:block;flex:1 1 0;min-width:0;border-radius:2px 2px 0 0;align-self:flex-end;}
/* The failure block. Loud enough to be seen, quiet enough not to look like an outage
   banner for the whole site: it belongs to one widget and says so by sitting inside it. */
.asFail{
  display:flex;align-items:center;gap:10px;flex-wrap:wrap;
  padding:12px 14px;border-radius:10px;
  border:1px solid rgba(245,132,101,.38);background:rgba(245,132,101,.08);
  font-size:12.5px;line-height:1.4;color:var(--ink,#E8EFE9);
}
.asFailDot{width:8px;height:8px;border-radius:50%;background:#F58465;flex:0 0 auto;}
.asFailBtn{
  margin-left:auto;background:rgba(232,239,233,.10);border:1px solid rgba(232,239,233,.30);
  color:inherit;border-radius:100px;padding:6px 13px;font:inherit;font-size:12px;
  font-weight:600;cursor:pointer;white-space:nowrap;
}
.asFailBtn:hover{background:rgba(232,239,233,.16);}
.asFailBtn:disabled{opacity:.6;cursor:default;}
.asFailBtn:focus-visible{outline:2px solid var(--cheap-lite,#5FD394);outline-offset:2px;}
@media (prefers-reduced-motion:reduce){
  .asSkel::after{animation:none;opacity:.5;}
}
