/* ==========================================================================
   Astral Sovereignty — "App shell" concept layout (first pass)

   The same screens, the same nodes and the same routes, arranged like an
   installed phone app instead of a web page: one slim line at the top, the
   alert strips under it, the five groups as a bar along the bottom, and the
   screen between them scrolling on its own while none of the chrome moves.

   Loaded alongside game.css on every page; EVERY rule here is scoped under
   `.shell-app` — a class ui/appShell.js puts on #app when it assembles, and
   app.js clears at the top of every render — so with the class absent this file
   changes nothing at all. tests/shell_concept.test.js pins that scoping.

   WHY THE CLASS IS ON #app AND NOT ON <html>. The preset's class is set
   pre-paint from an inline read because a skin can be applied to any DOM. This
   is not a skin: these rules assume the game shell's arrangement, so on login,
   shard select or the public status page they would be wrong, and while the
   shell module is still being fetched they would be wrong about the standard
   arrangement underneath them. Owned by the thing that builds the arrangement,
   applied at the same moment, and there is no state where the CSS and the DOM
   can disagree.

   Discipline (do not relax these):
   - game.css IS NOT TOUCHED. The header/tab-bar geometry it pins
     (tests/header_layout.test.js, tests/tick_chip.test.js) is overridden here,
     inside the scope, and nowhere else. Standard-shell players are provably
     unaffected — this file cannot reach them.
   - STRUCTURE ONLY, no colours of its own. Every colour below is one of
     game.css's tokens, which .preset-command retunes — so Command paints this
     arrangement exactly as it paints the standard one, and both can be on at
     once. Loaded AFTER preset-command.css so a geometry rule of ours beats a
     paint rule of theirs at equal specificity, never the other way round.
   - Nothing here reads or moves the ALERT STRIPS' own rules. They keep their
     paint, their copy and their place in the reading order; all this file does
     is take them out of a sticky stack they no longer need, because in this
     arrangement nothing scrolls underneath them.
   - Transform and opacity only in the one animation, and it carries its own
     prefers-reduced-motion guard on top of game.css's global kill switch. No
     width/height/top/left transitions: they are layout on every frame, which is
     what makes a low-end Android phone drop them.
   - No backdrop-filter. In this frame nothing scrolls behind the bars, so the
     blur buys nothing and costs a compositor pass per frame on cheap hardware.
   ========================================================================== */

/* ---------- 1. The frame -------------------------------------------------- */

/* #app becomes the window: exactly one viewport tall, a column of fixed bands
   with one scrolling band in the middle. Everything else in this file follows
   from that single decision.

   `height`, not `min-height`: the whole idea is that the chrome does not move,
   and an #app that can grow is a page that scrolls its chrome away.

   Deliberately NO `overflow: hidden`. If the chrome ever did exceed the
   viewport — a combat strip naming a dozen fleets, on a 320px phone, with the
   top line wrapped — clipping it would take the bottom bar off the screen with
   no way to reach it. Left visible, that case scrolls the page instead: ugly,
   and reachable, which is the right way round. */
.shell-app { height: 100dvh; }

/* THE BOTTOM BAR MUST SURVIVE A PULL-DOWN, and this pair is what makes it.
   The bar is `position: static` at the foot of a 100dvh flex column — nothing
   pins it to the viewport — so anything that moves the PAGE moves the bar off
   the bottom edge. <main> already contains its own overscroll (§6), but a pull
   that starts on the header, the strips or the bar itself has no scrollable
   ancestor short of the viewport, and with the root's default
   `overscroll-behavior: auto` Chrome answers that pull itself: pull-to-refresh,
   plus Android 12's stretch effect, both of which translate the whole document
   downward mid-gesture. Player-visible result, reported 2026-08-19: pull down
   at the top of a screen and the nav bar slides off the bottom — "the bar
   disappears" — until the gesture settles (or the page reloads under their
   thumb). `contain` on the root stops both: no refresh, no stretch, while
   ORDINARY page scrolling still works, which §1 deliberately preserves for the
   chrome-taller-than-viewport case. The in-app Refresh button in the header is
   the refresh gesture's replacement, exactly as it already is inside <main>.
   Holds in Chrome Android and in the TWA (same engine; the TWA's swipe-refresh
   honours the same root overscroll-behavior).

   `:has`, because the frame's class lives on #app (see the header comment for
   why) and the root is ABOVE it: this is the one thing the arrangement needs
   that only an ancestor can carry. Both selectors still key on .shell-app, so
   with the class absent this file keeps changing nothing at all — and app.js
   clearing the class at the top of a render un-matches these the same instant.
   On html AND body both, because Chrome has historically read the viewport's
   overscroll off either depending on which one ends up the scrolling box. */
html:has(> body > #app.shell-app),
body:has(> #app.shell-app) { overscroll-behavior-y: contain; }

/* The bands keep their own height; the screen absorbs everything left over.
   Written out one by one rather than as `> *` so a screen's own absolutely
   positioned children can never pick it up. The two ALERT strips are matched as
   descendants, not children: both are filled in place inside a `display:
   contents` mount point (#combat-strip, #shard-strip), so the strip itself is a
   grandchild of the root while still laying out as a direct flex item. */
.shell-app > .shell-header,
.shell-app .shard-shutdown-strip,
.shell-app .combat-strip,
.shell-app > .sub-tabbar,
.shell-app > .tabbar { flex: 0 0 auto; }
/* The notice is the one band that is NOT in that list — it is the only one the
   player can make taller, so it is the only one that has to be able to give
   way. Its rule is in §5. */

/* ---------- 2. The top line ----------------------------------------------- */

/* One line: shard identity, the clock, refresh, mailbox. game.css gives the
   in-game header a FIXED height (5.65rem below 800px — two rows, because the
   clock owns one of them there). Here it is content-height and normally one
   row; it is allowed to wrap, because the alternative when a line genuinely
   does not fit is a clipped clock or a page that scrolls sideways. */
.shell-app > .shell-header--game {
  height: auto;
  min-height: calc(var(--tap) + .3rem);
  flex-wrap: wrap;
  align-items: center;
  align-content: center;
  column-gap: .4rem;
  row-gap: .15rem;
  padding: .15rem .55rem;
  /* flex-start now that .header-break defines the rows explicitly rather than
     leaving them to whatever happened to fit. flex-end was there to keep a
     dropped mailbox under the buttons it belongs to instead of stranded on the
     left — with an explicit break there is nothing left to drop, and flex-end
     would push the lone clock on row one to the right, away from the hamburger
     the eye starts at. .spacer still holds the buttons hard right on row two. */
  justify-content: flex-start;
}

/* THE WORDMARK STAYS, and the rule that hid it is gone. It read "an installed
   app does not print its own name across its own title bar — the shard is the
   identity that changes", which was a fair argument about a line that carried a
   shard chip and a clock. It is not one any more: the shard name left the header
   entirely (it is in the drawer now), and the owner asked for the game's name on
   the top row in its place — "remove shard name and put game name there". They
   then went looking for it in this arrangement and could not find it, which is
   the whole story of why this rule was wrong to keep.
   It fits without argument now that the break gives row 1 to the clock alone:
   at 360px the content box is 342.4px and the wordmark plus the gap plus the
   clock is 135.41 + 6.4 + 143.2 = 285px. Section 9's desk-width restore went
   with it — there is nothing left to restore. */

/* THE ROW BREAK STAYS ON, and this rule's own history is the argument for it.
   It used to be `display: none` here, because this arrangement's premise is one
   slim line. That premise was written when the line held a shard chip, a clock
   and two buttons. It now holds a hamburger, a 40px crest, an empire name, a
   rank pill, Refresh and the mailbox, and one line is a thing it can no longer
   be: measured at 412px with the break suppressed, the header was 96.2px tall
   across FOUR ragged wrap rows, with the empire name given 40.3px of the 136px
   it needs — "Examp…". Letting the break fire puts the clock on one row and
   everything else on the other, which is both tidier and SHORTER (~78px), and
   gives the name ~197px. A premise that no longer survives its own content is
   not a premise worth keeping. */

/* THE HAMBURGER, and whether it belongs in an arrangement that already has a
   five-button bar along the bottom.
   It does, and the reason is what each of the two is FOR. The bottom bar is the
   five NAV_GROUPS — the loop a player runs ten times an hour. The drawer is
   account, which shard, interface style, combat alerts, About and the server's
   own status page: things touched once a week. Since 2026-09-10 the settings
   screen is a LEAF of the Empire group rather than a destination of its own, so
   the overlap is the drawer's Settings rows landing on cards of a screen the
   Empire sub-strip also reaches — the same duplication as before, one tier
   lower, and the same one the standard shell carries.
   It also costs this line nothing in height, which was the real risk and is
   measured rather than assumed: at 360px in live mode (no "Exit demo", mailbox
   present) this header is 96.17px and spends two rows WITH the hamburger and
   two rows WITHOUT it — the clock's reserved box plus the identity block's
   112px floor plus two 44px buttons already exceed the 342.4px content box.
   <main> is 578.6px either way.

   IT NEEDS NO RULE OF ITS OWN ANY MORE, and this note is here so the deleted one
   is not reinstated. It used to be `order: -1; margin-right: auto`, written when
   both of that rule's premises held: the row break was display:none here, so DOM
   order put the button after the clock, and the line was justify-content:
   flex-end, so only an auto margin could keep it left. Both premises are gone —
   the break fires (above) and the line packs from the start (below) — and with
   them the rule turned harmful: order:-1 hoisted the button PAST the break onto
   the clock's row, leaving the break stranded as a third line of its own.
   Measured at 412px: three rows, 98.6px, the empire name given 115px. In DOM
   order it is simply the first thing on row two, hard left, which is where the
   owner asked for it and where the standard shell has it. */

/* The clock never gives way. It is the single most-looked-at element in the
   game, so on this line it is the rigid one: no shrink, no ellipsis, full text
   in every state (game.css 3b's full-width row and its centring are undone —
   they exist to give it a line of its own, which is exactly what this
   arrangement is trying not to spend).
   The shard chip is the elastic one instead: it truncates, and tapping it opens
   More where the name is written out in full — the same trade the standard
   header already makes. */
.shell-app > .shell-header--game .tick-chip {
  order: 0;
  flex: none;
  height: auto;
  justify-content: flex-start;
  /* Tighter than the standard chip's .7rem: on this line every 6px is a
     character of shard name, and the chip's own padding is the only fat on it
     that is not the clock. */
  padding: .2rem .5rem;
  gap: .35rem;
  /* "The clock never gives way" is the rule above, and it holds at every size a
     phone shows by default — but it cannot hold absolutely. At 200% system text
     the string alone is 361px on a 360px screen, and `flex: none` means it does
     not shrink: the row is `justify-content: flex-end`, so the surplus went off
     the LEFT edge and the countdown was partly unreadable and unscrollable-to.
     A cap turns "never gives way" into "gives way last": game.css already makes
     .tick-text an ellipsis box, so past this point the clock shortens like any
     other label instead of leaving the screen. It never binds at a normal text
     size — the chip is 185px of a 325px row. */
  max-width: 100%;
}
/* THE CLOCK RESERVES ITS OWN BOX, and this is the one rule here that exists to
   stop the screen MOVING rather than to place something.

   This header is content-height and allowed to wrap (above), so its height is a
   function of how wide its widest item is — and its widest item is a string that
   rewrites itself every second and changes LENGTH as it goes: "orders lock in
   1m 00s" (21 characters) becomes "…59s" (18), becomes "…9s" (17). Cross a
   width boundary and the line re-wraps, the header loses a row, and everything
   below it — the whole screen, mid-tap — jumps 46.4px. Measured at 390px, the
   shipped rule does exactly that in the LAST NINE SECONDS before orders lock,
   which is the worst possible moment: the player is on Cancel.

   .tick-chip is monospace (game.css), so `ch` is exact. It WAS 17ch, "Next tick
   1:02:09" — the whole string the chip used to hold. The chip now splits in two
   (core/format.js tickChipView): the tick's NUMBER is its own segment and the
   words went with it, so what this box has to hold is the countdown ALONE, and
   "1:02:09" is 7 characters — exactly 7ch in a monospace face, which is why the
   reserve can be this tight without ever clipping a digit.
   Reserving it makes every shorter countdown occupy the same box, so nothing
   re-wraps while the clock runs down. Re-measured in headless Chrome over the
   countdowns the chip walks through (0:09 -> 17:54 -> 59:59 -> 1:02:09), Mobile
   layout, demo: the .tick-text box is 51.55px at every one of them at both 360
   and 390, the chip is 134.08px throughout, and the header stays 82.56px — one
   row, no jump. (It was 17ch/117.59px reserved and a 185px chip.) The number
   segment beside it is the elastic one and does not re-wrap the line either —
   it ellipsises first (game.css .tick-num).
   Deliberately NOT wide enough for the operator's status label (up to 28
   characters). That one still re-wraps — and it should: a shard in maintenance
   is a state change the player needs to notice, not a digit ticking over.

   CAPPED AGAINST THE VIEWPORT, and the cap is not dressing — without it this
   rule is a bug at large system text. `ch` scales with the font, so at 200% text
   even this box is a third of a 360px phone; the row is `justify-content:
   flex-end`, so an item wider than the row overflows to the LEFT, and the clock
   ran 19px off the edge of the screen with no way to scroll to it. The cap is
   `vw` and not `100%`: percentages here resolve against the chip, which is
   `flex: none` and therefore sized by this very text, so `100%` is circular and
   constrains nothing (measured: it changed the overflow by 0px). It came down
   from 60vw with the reserve itself — a box a third the width needs a third of
   the escape hatch. Reserving "as much as the screen can actually give" keeps
   the countdown steady at every size a phone can show it, and never by holding
   it steady off-screen. */
.shell-app > .shell-header--game .tick-chip .tick-text { min-width: min(7ch, 30vw); }
/* Elastic, but with a FLOOR. The elastic item is the IDENTITY BLOCK now, not the
   shard chip inside it — the chip stopped being a direct child of this line when
   the header grew a crest and an empire name (game.css section 3), and a flex
   rule aimed at a grandchild sizes it against the wrong box entirely.
   Squeezed below about four characters the block stops being a truncated name
   and becomes an empty box with an ellipsis in it, which reads as a bug rather
   than as "tap me for the rest" — measured at 360px, where the clock and two
   44px buttons leave it 23px. The floor is also its flex-basis, so it is what
   the line-break maths counts: when the line genuinely cannot hold a readable
   name, the header takes a second row instead of pretending it fitted. The floor
   is the crest (40px) plus the ~3rem of name that made the old one readable. */
.shell-app > .shell-header--game .empire-id {
  flex: 1 1 calc(3rem + 40px);
  min-width: calc(3rem + 40px);
  max-width: max-content;
}

/* ---------- 3. The alert strips ------------------------------------------- */

/* The one thing in this arrangement that is not up for discussion: a shard
   closing and a fleet in combat stay above the screen, on every screen.
   In the standard shell they are `position: sticky` and follow the page down.
   Here the page does not move — the screen scrolls inside its own box — so
   there is nothing to follow and nothing to compute an offset against; they are
   permanently on screen by construction. `static` says that out loud rather
   than relying on a sticky element that happens to have no scrollport, and it
   is the only declaration this file writes about either strip. Paint, copy,
   dismissal and the mount point's `display: contents` are untouched.

   THE HEADER GOES WITH THEM, and leaving it out was a bug. game.css pins
   `.shell-header { position: sticky; top: 0; z-index: 40 }`. Unpinning the two
   strips also drops their `z-index: 40`, so they land in the unpositioned paint
   layer — BELOW a header that is still positioned. That is invisible until the
   one case §1 deliberately left reachable: chrome taller than the viewport, so
   the page itself scrolls (landscape, or 200% system text, with a shard closing
   AND a fleet in combat AND the notice opened). Then the header alone stays
   pinned at the top and the two alert strips slide up UNDERNEATH it — the file's
   own non-negotiable, broken in exactly the state that makes those strips
   matter most. With all three static the chrome scrolls as one band and the
   strips stay above the screen, which is what §3 says it guarantees. */
.shell-app > .shell-header,
.shell-app .shard-shutdown-strip,
.shell-app .combat-strip { position: static; }

/* Same argument §7 makes for the bottom bar, and it was only ever half-applied:
   game.css gives the header `backdrop-filter: blur(8px)` because in the shipped
   shell the page scrolls behind it. Here nothing does, so the blur has nothing
   to blur and costs a compositor pass per frame on exactly the cheap Android
   hardware this arrangement is for. */
.shell-app > .shell-header { backdrop-filter: none; }

/* ---------- 4. The group's sections --------------------------------------- */

/* Sub-tabs stay directly under the top line, as a segmented strip — the
   platform arrangement everywhere: the bottom bar is for top-level
   destinations, an in-page control is for the sections of one of them. Putting
   them above the bottom bar instead would stack two rows of navigation on top
   of each other at the one end of the phone where a mis-tap costs you a whole
   screen, and would cost the content another ~44px at the same time.
   They also stop scrolling away here, which the standard strip does — five
   Empire sections that are always in reach is most of the point.
   The five DO have to fit: `1 0 auto` shares the width out evenly and refuses
   to shrink below the label, so a strip that cannot fit scrolls sideways
   (game.css already makes it a scroller) rather than clipping a word. */
/* ONE ROW THAT SCROLLS. This rule used to say "WRAP, don't scroll", and the
   argument it made was that a section off the side of the screen with nothing
   saying so is worse than a second row of chrome. The owner overturned it on
   2026-09-14 looking at the Colonies group on a 412px phone: Colonies ·
   Designer · Orbitals · Research · Officers wrapped to TWO rows, 93px instead
   of 45px, on a screen that audit A4 F4 already measured as half chrome before
   a single fact — 165px of header plus a 220px contact strip plus this.

   The reachability the wrap was bought for is bought twice over without it:
   game.css already makes this strip a sideways scroller, and app.js
   revealActiveTab() scrolls it to the section you are standing in on every
   render (core/nav.js activeTabScrollLeft — the same arithmetic the main bar
   and the tier-3 strips use). So the current section is always on screen, and
   game.css fades whatever is clipped at either edge, which is the strip saying
   out loud that there is more of it. Nothing is ever off the side with nothing
   to say so.

   AND THAT HOLDS AT EVERY TEXT SIZE, which is why there is no large-text
   exception here and no threshold to keep in sync. The bottom bar in §9 wraps
   at 200% system text because its slots are FIXED and its labels TRUNCATE — two
   destinations reading "Co…" is a bar that lies about itself. This strip's
   labels never truncate; they scroll. Larger text makes the row longer, not
   less readable, and the scroll-to-current keeps the answer in view.
   Measured at 412x915, demo mode: Colonies 93px -> 45px, and no group on any
   viewport this client is designed against wraps any more. */
.shell-app > .sub-tabbar {
  flex-wrap: nowrap;
  padding: 0 .3rem;
  border-bottom: 1px solid var(--line);
}
/* `1 0 auto`: the sections share a row that has room to spare — three Map
   sections still fill the width rather than huddling at the left — and refuse
   to shrink below their own label, so a group that cannot fit overflows into
   the scroller above instead of clipping a word.
   .4rem and not .5rem is left over from the wrap and still earns its place:
   Command uppercases these labels and letter-spaces them, and the tighter
   gutter buys 16px back, which is 16px less scrolling on the groups that
   overflow. */
.shell-app > .sub-tabbar a {
  flex: 1 0 auto;
  justify-content: center;
  padding: .3rem .4rem;
}

/* ---------- 5. What this is ----------------------------------------------- */

/* The concept's honesty, in the chrome, on every screen. A shell is on ALL the
   time — unlike a screen concept there is no other screen to walk back to — so
   somebody who cannot remember switching it on has to be able to find out what
   they are looking at from the thing they are looking at. One line closed; the
   gaps come straight from the registry entry when it is opened. */
/* The notice YIELDS; the screen does not. It is listed with the fixed bands
   above, but unlike them it can be opened by the player — and an opened notice
   that refuses to shrink takes 44% of a 360x800 phone, or at 200% system text
   pushes the bottom bar off the bottom of the screen entirely. `0 1 auto` lets
   it give way when the frame runs out of room; its body already has its own
   scroller (below), so what it gives up it absorbs there rather than clipping.
   The min-height keeps the summary row itself tappable no matter how hard it is
   squeezed — the row is the way to close it again. */
.shell-app > .shell-app-note {
  flex: 0 1 auto;
  min-height: 2rem;
  border-bottom: 1px solid var(--line-soft);
  background: var(--bg-1);
  color: var(--text-3);
  font-size: var(--t-xs);
}
/* The row is its own caret (game.css's .fold-caret, which it also rotates when
   the details opens), so the native marker goes — a flex summary drops it in
   most engines anyway, which is why every other fold in the app draws its own. */
.shell-app .shell-app-note > summary {
  display: flex;
  align-items: center;
  gap: .4rem;
  min-height: 2rem;
  padding: .1rem .6rem;
  cursor: pointer;
  user-select: none;
  list-style: none;
}
.shell-app .shell-app-note > summary::-webkit-details-marker { display: none; }
.shell-app .shell-app-note__tag {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--text-4);
}
/* Capped and scrollable: an opened notice must not be able to squeeze the
   screen out of its own frame. min() and not 40dvh alone — on a tall phone
   40dvh is over 300px of explanatory text, and this is a footnote about the
   furniture, not a screen. */
.shell-app .shell-app-note__body {
  max-height: min(40dvh, 14rem);
  overflow-y: auto;
  padding: 0 .6rem .4rem;
}
.shell-app .shell-app-note__gaps { margin: .2rem 0 .4rem; padding-left: 1.1rem; }

/* ---------- 6. The screen ------------------------------------------------- */

/* The only thing on the page that scrolls — and it has a FLOOR.
   A flex item refuses to shrink below its own content unless min-height says
   otherwise, which is why this was `min-height: 0`: without it a long colony
   page would push the bottom bar off the viewport. But `0` set no floor either,
   and every band above is `flex: 0 0 auto`, so the shrinking had no end. At
   400% browser zoom, in an Android split-screen window, or on a short landscape
   phone with a combat strip up, <main> was squeezed to a slit — measured at
   16px of game under 435px of furniture — and the player could not read the
   fleet the strip was warning them about.
   40vh does both jobs at once: any explicit min-height lifts the
   shrink-to-content restriction exactly as `0` did, and this one also says
   where the shrinking stops. Past that point the chrome yields instead — the
   frame overflows the viewport and the PAGE scrolls, which §1 deliberately kept
   possible for this case, and which only works properly now that §3 has made
   the whole chrome static so it scrolls as one band. Ugly and reachable beats
   tidy and unusable. */
.shell-app > main.game-main {
  flex: 1 1 auto;
  min-height: 40vh;
  overflow-y: auto;
  overflow-x: hidden;
  /* `contain`, NOT `none`. `none` also kills the rubber-band both iOS and
     Android draw when you hit the end of a list, which is the platform telling
     the player they are at the bottom — fighting that is exactly what makes a
     web app feel wrong. `contain` keeps the bounce and stops the scroll
     CHAINING out to the page behind the frame (and stops a pull at the top of a
     list turning into a browser reload). */
  overscroll-behavior-y: contain;
  -webkit-overflow-scrolling: touch;
  /* Anything scrolled to by ui/orders.js's section buttons lands clear of the
     top edge instead of flush against the strip above it. */
  scroll-padding-top: .5rem;
  animation: shell-app-enter .16s ease-out both;
}

/* The route change, and nothing else: the chrome is what stays put in an app,
   so only the screen moves. Transform and opacity only — both composited, so a
   low-end Android phone animates them without a layout pass — and it ends on
   `transform: none` rather than a zero translate, so the finished screen leaves
   no transform behind to become a containing block for a modal opened later. */
@keyframes shell-app-enter {
  from { opacity: 0; transform: translate3d(0, 8px, 0); }
  to   { opacity: 1; transform: none; }
}

/* game.css's global kill switch already clamps every animation to .01ms when
   the device asks for reduced motion. This is the belt to that pair of braces,
   and the rule the Command preset's header comment sets for any stylesheet that
   adds an animation of its own: turn it off, here, in the file that added it. */
@media (prefers-reduced-motion: reduce) {
  .shell-app > main.game-main { animation: none; }
}

/* ---------- 7. The bottom bar --------------------------------------------- */

/* The same five groups, the same node app.js builds for the top strip
   (ui/appShell.js re-parents it — it does not build a second one), moved to
   where the thumb already is.

   WHAT ACTUALLY CLEARS THE GESTURE BAR, because the obvious reading of the
   line below is wrong and would mislead the next person to touch it.
   index.html's viewport meta is `width=device-width, initial-scale=1` with no
   `viewport-fit`, which means `auto` — the browser lays the page out INSIDE
   the safe area, so the bar is already clear of the Android gesture pill and
   the iOS home indicator before this file says anything. In that mode
   env(safe-area-inset-bottom) resolves to 0 on every device (the insets are
   only non-zero under `viewport-fit=cover`), so max() picks .1rem and the
   env() is inert.
   It is kept, and it is not decoration: it is what makes this bar correct on
   the day the client adopts `viewport-fit=cover` — the edge-to-edge look a
   real installed app has, where the bar's background runs under the home
   indicator instead of stopping above it. That is a change to a meta tag every
   screen shares, so it belongs to the standard shell's own pass and not to a
   concept (docs/client-vision.md §5.2). Until then max() is doing the work. */
.shell-app > .tabbar {
  position: static;
  gap: 0;
  overflow: hidden;
  padding: .1rem .2rem max(.1rem, env(safe-area-inset-bottom));
  border-top: 1px solid var(--line);
  border-bottom: none;
  background: var(--bg-1);
  backdrop-filter: none;
}
/* The bar's own tap height. --tap (2.75rem, 44px) is the client-wide FLOOR and
   stays the floor everywhere else; the owner asked for THIS bar a little
   bigger (2026-08-19) — it is the destinations the thumb lives on, at the
   screen edge where a rushed tap is shortest. 3.25rem is 52px: +18% on the
   target, taking the whole bar from a measured 48.2px to 56.2px (+17%) at
   360x800 — inside the asked-for 15–20%, and nothing here needs re-deriving
   because the app shell's chrome is MEASURED, not declared: --chrome-h comes
   off the DOM in app.js, and <main> is flex leftover (§6), so the content area
   follows the bar automatically.
   Declared on the frame AND on the toast stack in one rule, because #toasts is
   a SIBLING of #app (index.html) and inherits nothing from it — a token the
   toasts cannot see is how their clearance over the bar would silently go
   stale. Both selector parts key on .shell-app, so the scoping rule holds. */
.shell-app,
.shell-app ~ #toasts { --tabbar-tap: 3.25rem; }
/* One equal slot per group — five since 2026-09-10 — icon over label, each one a
   full tap target. `letter-spacing: normal` is a width decision, not a style
   one: every label has to fit 360px, and the Command preset's tracking is ~4%
   per character on the longest of them ("COLONIES"). Its uppercase, its colours and its glow all still apply.
   `.6rem` is the same kind of decision, and it is measured rather than chosen.
   The binding case is COLONIES under Command, which uppercase makes ~18% wider
   than "Colonies" — and it is not enough to *almost* fit, because
   `text-overflow: ellipsis` has to fit the ellipsis too: at .62rem it needed
   56.0px of a 55.8px box, and those two tenths of a pixel cost two whole
   characters ("COLONI…"). At .6rem it needs 54.2px, so every label is written
   out in full in both presets at the default text size, which is what almost
   everyone reads at — and a fifth of the width is more room than the sixth the
   measurement was taken at, never less. Larger system text ellipsises, as it
   should. */
.shell-app > .tabbar a {
  flex: 1 1 0;
  min-width: 0;
  flex-direction: column;
  justify-content: center;
  gap: .1rem;
  min-height: var(--tabbar-tap);
  padding: .25rem .1rem;
  font-size: .6rem;
  letter-spacing: normal;
  line-height: 1.15;
  border-bottom: none;
  border-top: 2px solid transparent;
}
/* The label shortens from the end instead of being sliced at both.
   Equal slots mean a fixed width per label, and the anchor used to carry
   `overflow: hidden` over centred text — which clips symmetrically and with no
   ellipsis, so the word loses BOTH ends and stops being a word: measured at
   360px, "Colonies" reads "olonie" at 150% system text (8.6px off each end),
   and under Command, which uppercases, it is already clipped at the DEFAULT
   size. An end-truncated "Colonie…" is still the name of the thing.
   The overflow moved from the anchor to the label, which also stops the anchor
   clipping the Command preset's active-tab glow — with the label truncating
   itself there is nothing left for the anchor to contain. */
.shell-app > .tabbar a .tabbar-label {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* PAST A POINT, TWO ROWS BEAT ONE. Truncation degrades gracefully right up
   until two labels truncate to the SAME text: at 200% system text on a 360px
   phone each slot holds about two characters, and with the six-group bar that
   meant Colonies and Combat both reading "Co…" — two primary destinations,
   indistinguishable, next to each other, for the player who most needs to read
   them. The 2026-09-10 restructure retired the Combat group and no two of the
   five collide at two characters any more, but this stays: two characters is
   not a word whether or not another slot happens to share them, and the rule
   below has no threshold to go stale.
   So at that point the bar wraps to a second row, which roughly doubles the
   width per label and writes them out in full again. Expressed in `rem`, so the
   trigger is the RATIO of text size to screen — the same 360px phone at 200%
   text crosses it, a tablet at 200% text does not, and no viewport width is
   hard-coded. It costs a second row of bar exactly where the alternative was
   two buttons that lie about which is which.

   Done with a flex-basis in `ch` and NOT a media query, which is the whole
   trick: `rem` and `em` inside a media query resolve against the browser's
   INITIAL font size, not the one in force, so a `max-width: 13rem` query is a
   fixed 208px and never matches a 360px phone however large the text is set
   (measured: it changed nothing at 200%). A flex-basis is evaluated in the
   element's own font, so N x 8ch simply stops fitting and the bar wraps by
   itself — one declaration, no threshold to keep in sync, and it responds to
   any way the text gets bigger, including browser zoom. */
.shell-app > .tabbar { flex-wrap: wrap; }
.shell-app > .tabbar a { flex-basis: 8ch; }

/* The current-tab marker moves to the top edge of the bar, where it is visible
   and where it points at the screen it belongs to.

   `--tab-current` and not `--blue` directly, and this is the one place where
   "borrow the paint" needed more than a token. Moving the marker moved it to a
   PROPERTY the Command preset does not address: the preset marks the current
   tab with `border-bottom-color: var(--cyan)` in both strips, and this bar has
   no bottom border any more. It also does not retune `--blue`. So the bar drew
   a #2e75f2 marker two thumb-widths below a cyan one — the only place in a
   Command client where the current thing is not cyan, and a straight breach of
   this file's own "Command paints this arrangement exactly as it paints the
   standard one".
   Fixing it in preset-command.css alone cannot work: `.preset-command .tabbar
   a[aria-current="page"]` and this selector are both (0,3,1) and this file loads
   last, so the preset would lose the cascade. Fixing it here by naming --cyan
   under a .preset-command selector would put a colour decision, and knowledge of
   another axis, in a stylesheet that is supposed to have neither. So the token
   is the seam: the fallback is Classic's answer and lives here, and the preset
   overrides the token in its own token block, where every other Command colour
   decision already is. game.css is still untouched. */
.shell-app > .tabbar a[aria-current="page"] { border-top-color: var(--tab-current, var(--blue)); }

/* The toast stack is a SIBLING of #app (index.html), so it is the one selector
   in this file that reaches sideways instead of down — and it still does
   nothing without the class. Lifted clear of the bar: toasts are fixed at
   z-index 90 and fire the instant a button is tapped, so left where they are
   they would cover the tab bar for six seconds. Built from --tabbar-tap, the
   same token the bar's height is (declared on this selector too, in §7),
   rather than a copy of the number — when the bar grew from --tap to
   --tabbar-tap this line followed it by changing the token name, which is the
   point of sharing one. */
.shell-app ~ #toasts { bottom: calc(var(--tabbar-tap) + .9rem + env(safe-area-inset-bottom)); }

/* ---------- 8. Screens that measured the standard chrome ------------------ */

/* Two screens pin themselves against chrome that overlapped the page. Neither
   is edited: the same declarations are simply resolved against the box they are
   actually inside in this arrangement.

   The ship designer's vitals bar sticks under --chrome-h (measured by app.js).
   Here the chrome overlaps nothing, so the offset is the top of the scroller. */
.shell-app .design-vitals { top: 0; }

/* The map sizes its stage as the viewport minus the header minus the tab bar,
   for the same reason. Here <main> already IS what is left over, so the stage
   is all of it. ui/map.js is untouched and its ResizeObserver redraws the
   canvas when the box changes. */
.shell-app > main.game-main.wide { display: flex; flex-direction: column; }
.shell-app .map-shell { flex: 1 1 auto; height: auto; min-height: 0; }

/* ---------- 9. Wide screens ----------------------------------------------- */

/* Desktop is NOT designed here — this is a phone layout and says so, in
   Settings and on screen. This block exists only so that a wide window does not
   look broken while that is true: the buttons stop stretching to 280px each.
   The wordmark used to be restored here too; it is no longer hidden at any
   width, so there is nothing to restore. */
@media (min-width: 900px) {
  .shell-app > .tabbar { justify-content: center; }
  .shell-app > .tabbar a { flex: 0 1 9rem; }
}
