/* ============================================================================
 * mobile.css — the phone layout.
 *
 * EVERY rule is scoped under `.is-mobile`, set by mobile.js only when the mobile
 * view is active, so a desktop session cannot be reached by anything here.
 *
 * WRITTEN AGAINST THE APP'S REAL CLASS VOCABULARY, which was measured, not
 * assumed. The first cut of this file targeted .app-shell, .sidebar, .dlg,
 * .modal-actions, .toolbar, .pane — none of which exist. Those rules matched
 * nothing and the phone kept rendering the desktop shell, while every static
 * check passed, because CSS that matches nothing is silence rather than an
 * error. test/mobile-selectors-test.js now fails the build if this file names a
 * class the app never renders. Add to this file, then run that test.
 *
 * The real vocabulary, by frequency of use in the JS:
 *   .row (507) .card (213) .pad (196) .fld (176) .grid (55) .tag .btn .data
 *   shell: .app > aside.side + .nav-backdrop + main.main > .topbar + #view.view
 *   drawer: .app.mobile-nav-open .side   (already exists — do not reinvent it)
 *   dialog: .modal-back > .modal > .modal-head + .modal-body + .modal-foot
 *
 * The two rules everything follows from:
 *   1. NOTHING OVERLAPS — no absolute positioning that can collide, and any bar
 *      RESERVES its space rather than floating over the content.
 *   2. EVERY TAP TARGET >= 44px — a fingertip is that big, and a mis-tap in a
 *      marks screen is worse than a mis-tap in most apps.
 * ==========================================================================*/

/* ---- 1. the shell -------------------------------------------------------- */

/* One runaway wide table must never make the whole PAGE scroll sideways — that
   is what makes a phone layout feel broken even when everything else is right. */
.is-mobile, .is-mobile body { overflow-x: hidden; max-width: 100vw; }
.is-mobile .app { display: block; max-width: 100vw; overflow-x: hidden; }

/* THE DRAWER MUST FOLLOW .is-mobile, NOT A BREAKPOINT.
   The rules that take the sidebar OUT of the flow — position:fixed and
   translateX(-100%) — live in style.css inside @media (max-width: 820px), but
   the phone layout is switched on by THIS class, which mobile.js sets from the
   device (a coarse pointer on a small screen) rather than the viewport width.
   The two agree in portrait and disagree from 821px up.

   MEASURED at 900x414 with .is-mobile on: .side fell back to the desktop rule
   (position: sticky; height: 100vh) and the top bar rendered at y=414 — the
   whole app one full viewport below the fold, so the first screen was blank.
   That is any phone turned on its side (iPhone landscape ~844-932px, Pixel 8
   ~915px) and every device where somebody has tapped "📱 Mobile site", which
   is remembered in localStorage.

   So the out-of-flow rules are restated here, keyed to the class. The media
   query in style.css stays as it is — it is still the right answer for a
   narrow window on a desktop, where .is-mobile is off. */
.is-mobile .side {
  position: fixed; top: 0; left: 0; height: 100vh;
  width: min(84vw, 320px); z-index: 60;
  transform: translateX(-100%); transition: transform .22s ease;
  box-shadow: 2px 0 20px rgba(0,0,0,.22); flex-direction: column;
}
.is-mobile .app.mobile-nav-open .side,
.is-mobile .app.nav-collapsed.mobile-nav-open .side { transform: translateX(0); }
/* The icon-rail collapse is a desktop idea; on a phone the drawer always shows
   labels, so a collapsed shell must not leave it half-width and stranded. */
.is-mobile .app.nav-collapsed .side { transform: translateX(-100%); }
.is-mobile .nav-backdrop {
  display: block; position: fixed; inset: 0; background: rgba(0,0,0,.42);
  opacity: 0; pointer-events: none; transition: opacity .2s;
}
.is-mobile .app.mobile-nav-open .nav-backdrop { opacity: 1; pointer-events: auto; }
.is-mobile .side a, .is-mobile .side button { min-height: 48px; }
.is-mobile .nav-backdrop { z-index: 59; }

.is-mobile .main { margin-left: 0; width: 100%; max-width: 100vw; }
.is-mobile .view { padding: 10px 10px calc(20px + env(safe-area-inset-bottom)); max-width: 100vw; overflow-x: hidden; }

/* THE TOP BAR IS THE PAGE-LEVEL SIDEWAYS SCROLL.
   style.css gives .topbar a FIXED `height: 52px` and `display: flex`, and it
   carries a lot — school name, buy-tokens, quick actions, role switcher, sync
   dot, avatar. On a 375px screen those measure ~640px, so with no wrapping they
   run off the edge and drag the whole PAGE sideways: the app appears shifted
   left with its content cut off. My first version said `flex-wrap: nowrap`,
   which guaranteed it.
   It is `position: sticky`, not fixed, so letting it wrap simply makes it taller
   and pushes content down — nothing can end up underneath it. Height must become
   auto or the second row is clipped by the 52px. */
/* ONE LINE. Wrapping fixed the sideways scroll but traded it for a 211px header —
   four stacked rows of chrome before any content, which is most of a phone screen
   before you have read anything. So the bar stays a single 52px line and SCROLLS
   WITHIN ITSELF: every control stays reachable (clipping them would be worse than
   the overflow it replaces), the page never moves, and swiping the bar is the
   natural gesture for it. */
.is-mobile .topbar {
  flex-wrap: nowrap;
  height: 52px; min-height: 52px;
  gap: 6px; padding: 6px 8px;
  overflow-x: auto; overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;                 /* the bar is swiped, not scrollbar-dragged */
}
.is-mobile .topbar::-webkit-scrollbar { display: none; }
.is-mobile .topbar > * { flex: 0 0 auto; }
.is-mobile .topbar h1 { flex: 0 1 auto; font-size: 15px; max-width: 46vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.is-mobile .topbar button { flex: 0 0 auto; min-width: 44px; min-height: 40px; }
/* .spacer is flex:1 on desktop to push controls right; in a scrolling bar it
   would stretch to infinity and push everything else out of reach. */
.is-mobile .topbar .spacer { display: none; }

/* The global search claims width:100% inline, so on a phone it ate the whole bar
   and forced everything else onto later rows. Collapsed to an icon-sized field
   that expands when tapped — and it still reads as "search" at 44px because the
   .gs-ico glyph is absolutely positioned over the field's left padding, not
   because any of the placeholder fits (none of it does).
   THE COMMENT ABOVE USED TO SAY "the placeholder starts with 🔍", AND THAT WAS
   THE WHOLE MOBILE STORY: the collapsed box was legible only because the emoji
   happened to be the first character of an attribute. That made a phone layout
   quietly depend on a desktop cosmetic detail, so removing the emoji would have
   left a blank 44px box here with nothing to say what it was. It does not now —
   the glyph is a sibling element and survives the collapse on its own.
   padding-left is gone with it. It said 8px, sized for that leading emoji;
   app.js now sets padding-left:28px inline so typed text clears the glyph.
   Leaving the 8px here would be a second owner of one value that silently always
   loses — `.is-mobile #gsInput` and `.topbar #gsInput` are both 0-1-1-0, and
   inline beats either, so the rule would read as live and never apply. */
.is-mobile #gsInput {
  width: 44px !important; flex: 0 0 auto;
  transition: width .18s ease;
}
.is-mobile #gsInput:focus { width: min(70vw, 320px) !important; flex: 0 0 auto; }

/* THE WAY IN AND THE WAY OUT MUST NEVER SCROLL AWAY.
   Letting the bar scroll within itself (above) keeps every control reachable
   and stops the page moving — but "reachable" was doing a lot of work. MEASURED
   on a 375px screen: the bar's content is 631px, so everything from x=430
   onwards sat past the right-hand edge — the role chip, "People here", and,
   worst of all, PROFILE & SETTINGS at x=579, which is where Sign out and
   Switch school live. The only way to them was to swipe a 52px strip that gives
   no hint it can be swiped, so on a phone the app read as having no way out at
   all. That is exactly the report: "cannot operate, cannot log out".

   Sticky, not fixed: these two stay put at the edges of the SCROLLPORT while
   the rest of the bar slides underneath them, which is the pinned-column
   pattern and needs no change to the markup order. They carry the bar's own
   background so the scrolling content passes behind rather than through. */
.is-mobile .topbar .nav-toggle,
.is-mobile .topbar .tb-profile {
  position: sticky; z-index: 3;
  background: var(--panel); box-shadow: 0 0 0 6px var(--panel);
}
.is-mobile .topbar .nav-toggle { left: 0; }
.is-mobile .topbar .tb-profile { right: 0; }

/* And the three things a teacher actually opens are ordered into the space that
   is on screen without any swiping: menu, page title, Actions, account. The
   rest keep their order after them and are still reached by swiping — nothing
   is removed, it is only no longer FIRST in a queue that runs off the glass.
   (order works on the flex children; the DOM order, and so the tab order for a
   keyboard, is untouched.) */
.is-mobile .topbar > * { order: 5; }
.is-mobile .topbar .nav-toggle { order: 1; }
.is-mobile .topbar .ttl { order: 2; }
.is-mobile .topbar .tb-quick { order: 3; }
.is-mobile .topbar .tb-profile { order: 4; margin-left: auto; }

/* ---- 2. tap targets ------------------------------------------------------ */

.is-mobile button, .is-mobile .btn,
.is-mobile input, .is-mobile select, .is-mobile textarea {
  min-height: 44px;
  font-size: 16px;              /* under 16px makes iOS zoom the whole page */
  line-height: 1.25;
}
/* 44 IS A SQUARE, NOT A HEIGHT.
   A button is padding-sized, so a one-glyph label — "⋯", "🎙", "－", "Fit" —
   comes out 37-43px WIDE against a 44px-tall body. It reads as a full-size
   button and is not one, which is the worst kind of miss: nothing looks wrong.
   Four of the five phone layouts for the core screens each discovered this
   separately and each wrote its own inline `min-width:44px`; the fifth did not,
   and shipped a 42.5px microphone on the marks screen. One rule in the kit is
   the answer — the exemptions below (round dots, tick boxes) come after it and
   win, which is why this cannot be stated any later in the file. */
.is-mobile button, .is-mobile .btn { min-width: 44px; }
/* The size modifiers stay visually smaller but keep a full-size hit area. */
.is-mobile .btn.sm, .is-mobile .btn.xs { min-height: 44px; padding: 10px 14px; font-size: 15px; }
/* THE FAMILY TAB STRIP IS THE ONE THAT SLIPPED THROUGH. The rule above is an
   ELEMENT selector — `button`, `.btn` — so every other in-page tab row in the
   app is floored by it without asking (.co-tab measures 44px even though its
   own stylesheet says 40). page-tabs.js builds its tabs as plain `<a>` with an
   href, because they are real links to real routes, and an anchor matches
   neither. Measured at 37px on a 360px phone: the one strip in the product
   below the app's own 44px rule, and it is about to become the way most
   screens are reached. */
.is-mobile .page-tabs a { min-height: 44px; box-sizing: border-box; }

/* CIRCLES MUST STAY CIRCLES.
   A carousel pager dot is an 8x8 <button> with border-radius:50%. The blanket
   44px min-height above stretched it to 8 WIDE by 44 TALL — a vertical oval, on
   every screen with a pager. The rule was right in intent and wrong in reach: a
   deliberately tiny circular control needs a bigger HIT AREA, not a bigger body.
   So the visual size is restored and a transparent ::after gives it a ~40px
   target — larger to the thumb than before, while still round to the eye. */
.is-mobile .wpop-dot,
.is-mobile .wm-dots i {
  min-height: 0; min-width: 0;
  position: relative;
  aspect-ratio: 1;              /* squashed by a flex parent → still a circle */
  flex: 0 0 auto;
}
.is-mobile .wpop-dot::after { content: ''; position: absolute; inset: -16px; }
/* Any round control keeps its aspect under flex pressure — shrinking one axis is
   what turns a circle into an oval in the first place. */
.is-mobile .wpop-live-dot, .is-mobile .av { flex: 0 0 auto; aspect-ratio: 1; }

/* SAME MISTAKE, DIFFERENT CONTROL: a checkbox is ~13px square by default and
   `width: auto` (style.css:65), so the 44px floor above made it 13 WIDE by 44
   TALL — a blue column beside every band, division and column toggle in the app.
   A tick box is square or it is wrong. 22px is comfortably tappable and most of
   these sit inside a <label>, which is the real target anyway. */
.is-mobile input[type=checkbox], .is-mobile input[type=radio] {
  min-height: 0; width: 22px; height: 22px; flex: 0 0 auto;
}

/* ---- 2b. the drawer footer ----------------------------------------------- */

/* Theme · version · reset-menu sit in a `flex-wrap: wrap` footer. My 44px floor
   made each tall enough to wrap onto a second row, and the last one fell off the
   bottom of the drawer entirely — visible but unreachable, which is the worst of
   both. They are three small utilities, so they belong on ONE line: nowrap,
   tighter padding (the inline `padding:2px 8px` on the chip and the reset button
   needs !important to beat), and 40px rather than 44 because these are secondary
   controls in a drawer, not primary page actions.
   `· just now` is dropped: it is the widest part of the version chip and the
   least useful on a phone — the exact date is still in the chip's title, and
   tapping it opens the changelog either way. */
.is-mobile .side-foot { flex-wrap: nowrap; align-items: center; gap: 6px; padding: 8px; }
.is-mobile .side-foot > .btn {
  flex: 0 0 auto; white-space: nowrap;
  min-height: 40px; padding: 8px 10px !important; font-size: 13px;
}
.is-mobile .side-foot .btn .muted { display: none; }

/* ---- 3. rows wrap, they never squeeze ------------------------------------ */

/* .row is the app's flex row — 507 uses. Squeezing one is how two controls end
   up on top of each other, so on a phone it wraps instead. */
.is-mobile .row { flex-wrap: wrap; gap: 6px; align-items: center; }
/* A BUTTON IS AS WIDE AS ITS LABEL — nothing more.
   This used to say `flex: 1 1 auto`, which made every button in a row grow to
   fill the leftover space. On a row that wrapped, the last button landed alone
   on its own line and stretched the full width: a "✕ remove" turned into a
   345px empty slab, and three of them down a band list read as broken layout
   rather than as buttons. Growing was never the point — reaching 44px was, and
   the tap-target rule already does that. */
.is-mobile .row > .btn { flex: 0 1 auto; }
/* A grid of fixed columns becomes one column. */
/* minmax(0, 1fr), NOT 1fr. A grid track's default minimum is `auto`, which means
   "at least as wide as my content" — so a card holding a Chart.js <canvas> that
   reports a wider intrinsic size pushed its own column past the screen and had
   its right edge clipped (measured: Summary's two chart cards at 358px on a
   360px phone). A zero minimum lets the track shrink to the phone instead. */
.is-mobile .grid { grid-template-columns: minmax(0, 1fr) !important; gap: 8px; }
.is-mobile .grid > * { min-width: 0; }
.is-mobile canvas { max-width: 100%; }

/* ---- 4. text never lands on a control ------------------------------------ */

/* Long unbroken words — a school name, an email, a file name — are the usual
   reason text escapes its box and covers the button beside it. */
.is-mobile .card, .is-mobile .fld, .is-mobile td, .is-mobile th, .is-mobile label {
  overflow-wrap: anywhere;
}
.is-mobile .card { padding: 12px; }
.is-mobile .card.pad { padding: 12px; }
.is-mobile .fld { display: block; width: 100%; }
.is-mobile .fld input, .is-mobile .fld select, .is-mobile .fld textarea { width: 100%; }

/* ---- 5. tables ----------------------------------------------------------- */

/* A desktop table cannot be made to fit a phone; it can only be made to scroll,
   and a sideways-scrolling table is where "hard to tap" comes from. Opt a table
   into .m-cards and it restacks as one card per row, each cell labelled from its
   data-label. A grid that must stay a grid goes in .m-scroll, which contains the
   sideways scroll so the page itself never moves. */
.is-mobile table.m-cards, .is-mobile table.m-cards thead,
.is-mobile table.m-cards tbody, .is-mobile table.m-cards tr,
.is-mobile table.m-cards th, .is-mobile table.m-cards td { display: block; width: auto; }
.is-mobile table.m-cards thead { position: absolute; left: -9999px; }
.is-mobile table.m-cards tr {
  border: 1px solid var(--line); border-radius: 12px;
  padding: 10px 12px; margin: 0 0 10px; background: var(--panel);
}
.is-mobile table.m-cards td {
  display: flex; justify-content: space-between; gap: 12px;
  padding: 7px 0; border: 0; border-bottom: 1px solid var(--line); text-align: right;
}
.is-mobile table.m-cards td:last-child { border-bottom: 0; }
.is-mobile table.m-cards td::before {
  content: attr(data-label); font-weight: 600; color: var(--ink-soft);
  text-align: left; flex: 0 0 auto;
}
.is-mobile .m-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; max-width: 100%; }
/* Any table not opted in still must not break the page. */
.is-mobile .view table { max-width: 100%; }

/* ---- 6. dialogs become bottom sheets ------------------------------------- */

.is-mobile .modal-back { align-items: flex-end; padding: 0; }
.is-mobile .modal {
  width: 100%; max-width: 100%; max-height: 92vh; margin: 0;
  border-radius: 16px 16px 0 0;
  display: flex; flex-direction: column;
}
.is-mobile .modal-body { overflow-y: auto; flex: 1 1 auto; -webkit-overflow-scrolling: touch; }
/* .modal-foot is the real footer class (not .modal-actions). Stacked, full-width
   buttons, primary nearest the thumb. */
.is-mobile .modal-foot {
  position: sticky; bottom: 0;
  display: flex; flex-direction: column-reverse; gap: 8px;
  padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
  border-top: 1px solid var(--line);
}
.is-mobile .modal-foot .btn { width: 100%; }

/* ---- 7. the mobile kit --------------------------------------------------- */

.is-mobile .m-list { display: flex; flex-direction: column; gap: 8px; }
/* `--panel`, not `--card`: no theme in this app defines a `--card` token
   (style.css and skins.css ship --panel, --panel-2, --line). A rule written
   against it computes to transparent, so the rows quietly lose their card
   surface — the kind of miss that looks like a design choice rather than a bug. */
.is-mobile .m-row {
  display: flex; align-items: center; gap: 12px;
  min-height: 56px; padding: 10px 12px;
  background: var(--panel);
  border: 1px solid var(--line); border-radius: 12px;
}
.is-mobile .m-row-main { flex: 1 1 auto; min-width: 0; }
.is-mobile .m-row-main b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.is-mobile .m-row-sub { color: var(--ink-soft); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.is-mobile .m-row-end { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; }

/* A page action (Save / Add) spanning the width. It RESERVES its space via
   .has-actionbar below — a floating bar over the last row of content is exactly
   the "I can't tap it" complaint. */
.is-mobile .m-actionbar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 49;
  display: flex; gap: 8px;
  padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
  background: var(--panel); border-top: 1px solid var(--line);
}
.is-mobile .m-actionbar .btn { flex: 1 1 auto; }
.is-mobile.has-actionbar .view { padding-bottom: calc(84px + env(safe-area-inset-bottom)); }
/* …and so does everything else that parks itself at the bottom of the screen.
   The toast stack (bottom:16px) and the trial banner (bottom:16px) both landed
   ON TOP of this bar, covering the one control the page exists to offer — "🖨
   Print", "⋯ More", "Save". The bar already announces itself with a body class;
   these two just have to read it. 61px of bar + the 16px they each wanted. */
.is-mobile.has-actionbar #toasts { bottom: calc(77px + env(safe-area-inset-bottom)); }
.is-mobile.has-actionbar .try-banner { bottom: calc(77px + env(safe-area-inset-bottom)); }
/* Voucher entry: label above control on a phone. The desktop's fixed 180px
   label column left 129px for the field, so the 150px Amount box overhung the
   card's border (js/voucher-entry.js row()). */
.is-mobile .v-row { display: block; }
.is-mobile .v-row > div { flex: none !important; width: 100%; }

/* ---- 8. show / hide ------------------------------------------------------ */
.is-mobile .desktop-only { display: none !important; }
.is-mobile .m-only { display: revert; }
.m-only { display: none; }

/* ---- 9. compact ---------------------------------------------------------- */

/* A phone screen is a fifth of a desktop one, so desktop breathing room reads as
   holes: a heading with 16px above AND below it, a paragraph with another 16,
   and by the third one you are scrolling past emptiness to find the next
   control. Everything here is spacing only — nothing moves, hides or resizes. */
.is-mobile .view h1 { font-size: 21px; margin: 6px 0 4px; }
.is-mobile .view h2 { font-size: 18px; margin: 10px 0 4px; }
.is-mobile .view h3 { font-size: 16px; margin: 8px 0 4px; }
.is-mobile .view h4 { font-size: 15px; margin: 8px 0 3px; }
.is-mobile .view p { margin: 3px 0 8px; }
.is-mobile .view hr { margin: 10px 0; }
/* The stack of cards down a page — the single biggest source of dead space. */
.is-mobile .view > .card + .card, .is-mobile .view > .row + .card { margin-top: 8px; }
.is-mobile .m-list { gap: 6px; }
.is-mobile .m-row { min-height: 52px; padding: 8px 10px; gap: 10px; }
.is-mobile table.m-cards tr { padding: 8px 10px; margin: 0 0 8px; }
.is-mobile table.m-cards td { padding: 6px 0; }
/* .sm/.xs buttons keep the 44px hit area from section 2 but stop being wide. */
.is-mobile .btn.sm, .is-mobile .btn.xs { padding: 8px 12px; }
/* A bare form control is 44px tall already; desktop padding on top of that just
   makes it taller than the text inside it. */
.is-mobile input, .is-mobile select { padding: 8px 10px; }
/* Helper text under a heading belongs WITH the heading, not floating between. */
.is-mobile .muted.small { line-height: 1.35; }
