﻿/* =========================================================================
   mobile.css â€” the phone tier
   =========================================================================

   WHY THIS FILE EXISTS
   --------------------
   Before 2 Sep 2026 this portal had no phone tier at all. The smallest
   media query in the repo was 480px (login.html), so a 393px iPhone was
   served the same rules as a 700px tablet. Mobile treatment was a
   per-component, per-author judgement call: some of the newest surfaces
   were sophisticated about touch, others equally new had nothing.
   (Audit: plans/portal-mobile-audit-phase-one.md Â§3.4.)

   THE ONE RULE THAT MATTERS, AND THE REASON FOR THE FILE
   ------------------------------------------------------
   Zeal has spent essentially all of its development time on desktop.
   123 views depend on this shared CSS layer and desktop WORKS. A mobile
   improvement that regresses desktop is a far worse outcome than no
   mobile improvement at all.

   So this file has a hard structural constraint, and it is the whole
   point of putting the phone tier in one file rather than sprinkling it
   through the components:

       EVERY DECLARATION IN THIS FILE SITS INSIDE ONE OF EXACTLY THREE
       GATES, EACH OF WHICH IS PROVABLY INERT ON A DESKTOP BROWSER.

       1. @media (max-width: 430px)  â€” --bp-phone. Cannot match a desktop
                                       viewport. Not "unlikely to"; cannot.
       2. @media (hover: none)       â€” cannot match a mouse pointer.
       3. max(<existing>, env(safe-area-inset-*))
                                     â€” env() is 0px on any device with no
                                       notch or home indicator, and
                                       max(x, 0) is x. Returns the
                                       existing value unchanged.

   A declaration at the top level of this file â€” outside all three â€”
   would reach desktop, and it is the single way this file could hurt
   anything. That is a mechanical property, so it gets a mechanical
   check rather than a promise: scripts/check-mobile-contract.py parses
   this file and FAILS if it finds a declaration outside those gates.

   This is also what makes the file safe to link unconditionally on
   every page, which is what makes the tier real rather than opt-in. A
   tier each author has to remember to include is the situation this
   replaces.

   LOAD ORDER
   ----------
   Linked LAST of all stylesheets on every page, so it wins the cascade
   against the components without specificity tricks. It is deliberately
   linked BEFORE any page-local <style> block, because page-local blocks
   own page-local faults â€” the projects table fix lives in
   projects/dashboard.html next to the rules it neutralises, not here.

   WHAT IS DELIBERATELY NOT HERE
   -----------------------------
   * Anything that changes desktop. See above.
   * Page-specific fixes. If it is one page's problem, fix it on that
     page where the next reader will find it.
   * JS-dependent restacking (card views, a mobile nav, touch reordering
     for the planner). Those are product decisions, not CSS, and they
     are logged as open questions rather than assumed.

   House rules for adding to this file: docs/mobile-contract.md
   ========================================================================= */



/* =========================================================================
   1 — INPUTS: stop iOS zooming on focus
   =========================================================================
   iOS Safari zooms the page whenever a focused input's font-size is
   below 16px, and once zoomed the visual viewport diverges from the
   layout viewport and every position:fixed element starts misbehaving.
   --t-form has been 12px since e996fc3 (26 Apr 2026), a visual-polish
   commit that changed it from `var(--t-body, 16px)`. 20 pages load
   form.css and no page-local override exists anywhere.

   THE TOKEN IS NOT REVERTED GLOBALLY, AND THAT IS A MEASURED DECISION.
   A global `--t-form: 16px` was applied to the live /tools/vehicles.html
   at 2195px and the whole-page geometry compared: 53 elements moved.
   INPUT.input goes 31px -> 36px tall, .filter-bar 51 -> 56, main.p-main
   445 -> 451, and the search icon shifts 3px. That is a visible desktop
   change across 20 pages — a redesign nobody asked for.

   The same comparison with the phone-scoped block below: ZERO elements
   changed, geometry byte-identical. So the scoped fix is not a
   compromise, it is strictly better — the iOS behaviour at no desktop
   cost, and no argument with a designer later.
   ========================================================================= */

@media (max-width: 430px) {
  :root {
    --t-form: 16px;   /* phone only — desktop keeps 12px */
  }

  /* Belt and braces for anything that hardcoded a small size instead of
     reaching for the token. 16px is a FLOOR, not a size: max() leaves
     anything already larger alone. */
  input, select, textarea,
  .input, .select, .textarea {
    font-size: max(16px, var(--t-form));
  }
}


/* =========================================================================
   2 — TABLES: the shared "too wide" pattern
   =========================================================================
   Around a dozen table surfaces plus four div-row grids are wider than
   a phone with no min-width and no overflow container, so the table
   blows the viewport out and the whole PAGE scrolls sideways — which
   also drags the sticky ribbon and every fixed element off-centre.
   Measured live: /people/ 1536px in 6 columns, /tools/vehicles.html
   1536px in 7, both with min-width 0 and a parent at overflow-x:visible.

   THIS IS THE RIGHT TOOL FOR "TOO WIDE" AND THE WRONG TOOL FOR THE
   PROJECTS TABLE. That one's project link is zero pixels wide; a
   scroller lets you reach the columns and the link still has no hit
   area. It is fixed at source in projects/dashboard.html, and the
   wrapper it already had is exactly why a scroller alone was never the
   answer there.

   TWO MECHANISMS, ON PURPOSE
   * .table-scroll is the primitive for NEW markup: wrap the table, done.
     This is what the contract tells future authors to use.
   * :has(> table…) retrofits the tables that already shipped without a
     wrapper, so this needs no edits to a dozen React render functions
     and no markup change on any page. Every table is named explicitly
     rather than matched by a wildcard, so a table added somewhere new
     does not silently inherit a behaviour nobody chose — it opts in
     with .table-scroll, deliberately.

   TWO THINGS TO KNOW, BOTH DELIBERATE
   1. :has() is Safari 15.4+ / iOS 15.4+. On anything older the retrofit
      does not apply and those tables behave exactly as they do today.
      That is the correct way for this to degrade: no worse than now.
   2. `overflow-x: auto` forces overflow-y to compute to auto too — CSS
      offers no way to scroll one axis and leave the other visible. So
      on a PHONE, .table--sticky-header's header sticks to the top of
      the table's own scroll box instead of following the page. That is
      a real loss and it is being taken knowingly: an unreachable table
      is worse than a header that stops following. Desktop keeps the
      sticky header exactly as it is, because none of this applies
      above 430px.
   ========================================================================= */

@media (max-width: 430px) {

  .table-scroll,
  :has(> table.table),
  :has(> table.ds-table),
  :has(> table.apv-hist),
  :has(> table.plx-list__table) {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior-x: contain;
  }

  /* A floor below which columns stop being readable and start being a
     puzzle. Under the scroller this produces a sideways swipe rather
     than a crushed grid. 560px is the same floor the projects table
     uses, so the two behave alike. */
  .table-scroll > table,
  table.table,
  table.ds-table,
  table.apv-hist,
  table.plx-list__table {
    min-width: 560px;
  }

  /* rows.css calls itself "the portal's copy primitive" — a fixed 200px
     label column that never restacks, carrying VAT numbers, IBANs, wifi
     passwords and API keys (tools/company-info.html). At 393px more
     than half the width is gone before the value renders. Stack it. */
  .row {
    grid-template-columns: 1fr;
    gap: var(--s-1);
  }
}


/* =========================================================================
   3 — TOUCH TARGETS: 44 x 44 minimum, without moving anything
   =========================================================================
   Apple's guidance is 44x44pt. On /projects/dashboard.html, 636 of 641
   interactive elements are under it — 99%. Measured live: .pjx-chevbtn
   19x17 on every row, .pjx-celledit 113x21, .btn 99x25. This is not a
   per-page defect to be listed 123 times, it is the portal's default
   state, so it gets a design-system rule.

   THE DANGER, AND WHAT WAS DONE ABOUT IT
   Growing a control changes layout, and a layout change inside a table
   row is exactly how a "mobile improvement" breaks a working page. So
   NOTHING HERE GROWS THE VISUAL CONTROL. Every rule below expands the
   HIT REGION ONLY, via an ::after pseudo-element stretched to 44x44 and
   centred on the control. It is transparent, paints nothing, is
   position:absolute so it contributes no layout, and does exactly one
   job: receive taps. The control looks identical and its box is
   unchanged; the finger target around it is bigger.

   The only property that touches the element itself is position:relative,
   to give the overlay a containing block. On an inline-block, flex or
   grid child that is layout-neutral, and it was verified as such at
   1440px and 2195px rather than assumed.

   WHY A CURATED LIST AND NOT `button::after`
   Because a blanket rule would clobber the ::after of every control
   already using one for a caret, a chevron or an icon — and would do it
   only on phones, where it is least likely to be noticed. Each selector
   below was checked on the live page for an existing ::after and
   ::before; every one returned content:none. Anything not on this list
   opts in by adding .touch-target, which is the documented route.
   ========================================================================= */

@media (max-width: 430px) {

  .touch-target,
  .pjx-chevbtn,
  .pjx-zpid,
  .pjx-celledit,
  .btn,
  .ribbon__menu,
  .ribbon__avatar {
    position: relative;
  }

  .touch-target::after,
  .pjx-chevbtn::after,
  .pjx-zpid::after,
  .pjx-celledit::after,
  .btn::after,
  .ribbon__menu::after,
  .ribbon__avatar::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    width: max(100%, 44px);
    height: max(100%, 44px);
    transform: translate(-50%, -50%);
    /* not painted, not measured, not in flow — only tappable */
    background: none;
    border: 0;
    pointer-events: auto;
  }

  /* The project link is the portal's single most important tap target
     and it is 16px tall on every row. It deliberately does NOT get the
     overlay: a 44px overlay on a 16px link inside a ~40px row would
     extend past the row and swallow the neighbouring row's taps,
     trading one mis-tap for another. This one grows honestly, with
     vertical padding inside a cell that has room for it. */
  .pjx-projname a {
    padding-block: 12px;
  }
}


/* =========================================================================
   4 — HOVER-ONLY AFFORDANCES
   =========================================================================
   Six controls are revealed only on :hover, which means they do not
   exist on a touch device. Worst of them is .pl-fill__handle, the
   planner's drag-resize handle — a touch user cannot see it in order to
   find the thing they could not drag anyway.

   THE CORRECT PATTERN ALREADY EXISTS IN THIS REPO AND WAS NEVER
   CARRIED OVER. destructive-actions.css:57-62 and
   tier-functional.css:117-128 both pair the hover reveal with an
   @media (hover: none) fallback at opacity 0.45, documented inline
   against portal-decisions.md §2026-04-21. So the portal knows the
   right answer and wrote it down; it simply was not applied to
   planner.css, label-editor.css or racks.css. This applies it, at the
   same value, for the same reason: present but visibly quieter, so a
   touch user can find it without it shouting on every row.

   NOTE THE GATE: (hover: none), not a width. A hover-less tablet or a
   touchscreen laptop needs this exactly as much as a phone does, and
   gating it on width would have missed both. This is why the contract
   has three gates rather than one.

   THIS IS A VISIBILITY FIX, NOT AN INTERACTION FIX. Revealing the
   planner's drag handle does not make dragging work — HTML5 drag events
   do not fire from touch, so planner row reordering remains absent on a
   phone. That needs an alternative affordance (move up / move down, or
   a long-press handler) and is a product decision, logged in the audit
   as an open question. Making the handle visible without making it
   work is arguably worse than leaving it hidden, so if that trade is
   not wanted, .pl-fill__handle is the one line to remove here.
   ========================================================================= */

@media (hover: none) {
  .lm .set-card__del,
  .lm .set-card__pick,
  .pl-row__x,
  .pl-fill__handle,
  .rk-row__actions,
  .rk-job-row .unassign {
    opacity: 0.45;
  }
}


/* =========================================================================
   5 — SAFE AREAS
   =========================================================================
   Confirmed on Josh's iPhone: the last row of every list sits under
   Safari's floating bottom toolbar. This is an observed defect, not a
   theoretical one. The repo contained exactly ONE
   env(safe-area-inset-*) declaration in it — write-surface.css:192.

   THE PATTERN, AND WHY THESE RULES ARE NOT WIDTH-GATED
   Every rule here is max(<the value that was already there>, env(...)).
   On a device with no notch or home indicator env() is 0px, and
   max(x, 0px) is x, so the rule returns the existing value unchanged.
   That is why these can sit outside the width gate and still be
   provably desktop-inert — and why they must ALWAYS be written this
   way. A bare `padding-bottom: env(safe-area-inset-bottom)` would
   evaluate to 0 on desktop and DELETE the padding that was there. The
   max() is not decoration; it is the whole safety property.

   The existing value is repeated as a literal because CSS has no way to
   say "the current computed value of this property". That is a real
   maintenance cost: if one of those base values changes, the line here
   must change with it. Each rule therefore names the file and line it
   was taken from, so the pairing can be found.
   ========================================================================= */

/* The ⌘K palette becomes a full-width bottom sheet under 860px
   (menu.css:493-503, max-height 92vh). Its content ran under the home
   indicator. Base padding-bottom is 0. */
.pk-panel {
  padding-bottom: max(0px, env(safe-area-inset-bottom));
}

/* The sticky bulk-action toolbar is fixed to the bottom edge
   (destructive-actions.css:200-211, padding: 12px 32px). */
.toolbar__bulk--sticky-bottom {
  padding-bottom: max(12px, env(safe-area-inset-bottom));
}

/* The ribbon is sticky to the top on 64 pages. In LANDSCAPE the notch
   is on the SIDE, not the top — the case that gets forgotten, and the
   reason this is left/right rather than top.

   THIS ONE IS WIDTH-GATED, AND THE REASON IS A BUG THIS RULE ACTUALLY
   CAUSED. The "repeat the existing literal inside max()" pattern is
   only safe when there IS one literal to repeat. The ribbon has two:
   48px by default, and 24px (var(--s-5)) under ribbon.css's 720px
   block. An ungated max(var(--s-4), env(...)) was written here first,
   measured against the live page, and found to CUT the desktop
   ribbon's padding from 48px to 16px, moving 13 elements. It was
   caught by the verification rather than by review, which is the
   argument for the verification.

   So: gate it to the range where exactly one base value applies, and
   use that value. Both protections, not one. */
@media (max-width: 430px) {
  .ribbon {
    padding-left: max(var(--s-5), env(safe-area-inset-left));
    padding-right: max(var(--s-5), env(safe-area-inset-right));
  }
}

/* Long scrolling lists need somewhere for the last row to be that is
   not underneath the toolbar. Width-gated as well as env-guarded: on
   desktop there is no floating toolbar to clear, so the space would be
   a visual change for no reason.

   Scoped to .p-main rather than the bare `main` element, for the same
   reason as the ribbon above. Measured padding-bottom on .p-main is
   0px, so max() here can only ADD. A bare `main` selector would have
   reached any page whose main carries a larger bottom padding and
   reduced it to 32px. */
@media (max-width: 430px) {
  .p-main,
  .projects-list-view {
    padding-bottom: max(var(--s-6), calc(env(safe-area-inset-bottom) + var(--s-6)));
  }
}


/* =========================================================================
   6 — STAT TILES: wrap to 2 x 2 on a phone
   =========================================================================
   Reported from Josh's iPhone: the "142" in the first tile is cut off by
   the tile's edge, and "NO DATES YET" wraps onto three lines.

   THIS WAS MEASURED AS FINE ONCE AND IT WAS NOT. The phase-one audit
   recorded `scrollWidth 97 <= width 98` and concluded the tiles fit,
   attributing what Josh saw to "real iOS font metrics, exactly the class
   of thing only a device catches". Both halves of that were wrong, and
   the reason is worth keeping:

     * It measured the TILE against itself, not the NUMBER against the
       tile's CONTENT box — the space actually left inside the padding.
     * scrollWidth cannot answer this question anyway. .stat-tile__num is
       a stretch-aligned flex child, so its scrollWidth reports the BOX
       it was stretched to, never the ink. It can register an overflow
       and can never register headroom, so it reads "fits" at the exact
       moment a glyph is being cut.

   Measured properly — a Range over the text node, which returns the
   inked width — at 393px, with the real 3-digit value from the device:

     tile 86px, padding 24+24, so 37px of usable width
       "142"   ink 55.3px in 37px  ->  -18.0px   OVERFLOWS BY 33%
       "15"    ink 36.8px in 37px  ->   +0.4px   fits, by four tenths
       "22"    ink 36.8px in 37px  ->   +0.4px
       "79"    ink 36.8px in 37px  ->   +0.4px

   So the three-digit number clips in Chrome too — this was never
   iOS-only, it was measured on the wrong box. And the other three
   "fit" with 0.4px to spare, which is not slack, it is a coincidence.
   Any rendering engine a percent wider clips all four, which is exactly
   what the device did.

   At 375px (iPhone SE, still common) ALL FOUR clip in Chrome today:
   -22.5px, -4.1px, -4.1px, -4.1px.

   THE FIX IS NOT A SMALLER FONT. Shaving the number until it happens to
   fit is the same trap one iteration later, and the numbers are the
   point of the tiles. Wrapping to 2 x 2 roughly doubles the tile and
   turns the problem from a near-miss into a non-question:

     393px after:  "142"  +92.8px slack  (+168% headroom)
                   others +110.6px       (+300% headroom)
     375px after:  "142"  +83.6px slack  (+151% headroom)

   Headroom in multiples rather than pixels is the actual goal. No
   plausible font-metric difference between engines reintroduces this.

   It also fixes the labels — "no dates yet" goes from THREE lines to
   one, and "all projects" / "needs attention" from two to one — and it
   removes a latent break the audit flagged: `flex-wrap: nowrap` with
   four tiles filling 392px of a 393px viewport means a fifth tile, or a
   longer label, overflows with nothing to catch it. A wrapped grid is
   immune to that; a smaller font is not.

   Kept in mobile.css rather than on the projects page because
   .stat-tiles is a shared component — projects, intake, the person page
   and the user-admin time-off band all use it, and all of them have the
   same arithmetic problem on a phone.
   ========================================================================= */

@media (max-width: 430px) {
  .stat-tiles { flex-wrap: wrap; }

  .stat-tile {
    flex: 1 1 50%;
    max-width: 50%;
    /* 24px of side padding on an 86px tile was 56% of it. Half that
       here; the 2 x 2 wrap does most of the work but there is no reason
       to spend the width twice. */
    padding: var(--s-4) var(--s-3);
  }

  /* Borders follow the grid rather than the source order: no left rule
     at the start of a row, a top rule on every row after the first.
     Written with nth-child so it holds for 2 tiles or 6, not just 4 —
     the non-staff view of the projects page renders exactly 2.

     :not(.stat-tiles--filter) IS LOAD-BEARING. The base component
     separates cells with a single hairline `border-left`, so on a
     wrapped grid the left rule must be dropped at the start of each
     row. The --filter variant used on the projects page is a different
     thing: projects-ui.css:381 gives every tile `border: 1px solid` —
     they are bordered CARDS, not hairline-separated cells, and
     stripping border-left there takes the left edge off two of the
     four. Caught by reading back the computed border widths after the
     first version of this rule, which reported bl:0px on tiles 1 and 3
     of a row of cards. Nothing about the numbers would have shown it. */
  .stat-tiles:not(.stat-tiles--filter) .stat-tile:nth-child(odd) {
    border-left: none;
  }
  .stat-tiles:not(.stat-tiles--filter) .stat-tile:nth-child(n+3) {
    border-top: 1px solid var(--rule);
  }
}
