/* portal.css — the EBUTECH portal design system.
 *
 * Shared by BOTH signed-in areas of the website: the worker portal
 * (/worker/dashboard) and the customer account (/account). It deliberately
 * mirrors the mobile app screen for screen and borrows the app's tokens
 * verbatim, so someone who uses the app and the site does not feel like they
 * have moved between two products.
 *
 * One system, two portals: a change here lands on both, which is the point.
 * Anything genuinely specific to one of them belongs in its own file, not
 * here.
 *
 * Theming
 * -------
 * The app is a light surface with a gold accent. The portal supports that
 * light theme AND a dark one, because a worker is often on a phone outdoors at
 * night, and the previous portal was dark-only. The rules:
 *
 *   :root                                  → light tokens (the default)
 *   @media (prefers-color-scheme: dark)    → dark, unless the worker forced light
 *   :root[data-theme="dark"]               → dark, chosen explicitly
 *
 * Every colour is defined on bare :root first, so nothing depends on a media
 * query having matched. The toggle writes data-theme onto <html> and persists
 * it, which is why the explicit selectors have to win in both directions.
 *
 * The gold changes between themes on purpose: #DFA828 is tuned for contrast on
 * white and turns muddy on black, while #F0D060 is tuned for black and is
 * unreadable as text on white.
 */

/* ── Web fonts (self-hosted, same as style.css does for Inter) ──────────────
 *
 * Archivo carries the display sizes and every numeral in the customer
 * account; IBM Plex Sans carries the reading copy. The system stack they
 * replace is a large part of why the account read as generic.
 *
 * SELF-HOSTED ON PURPOSE, not loaded from Google. Three reasons, and the
 * first is the one that matters here: this product is used on Nigerian mobile
 * data, where a blocked or slow third-party font host means the premium type
 * silently disappears and nobody can tell you why. It also keeps the CSP shut
 * (no extra style-src or font-src origins) and removes a render-blocking
 * request to someone else's server. Every file is variable, so one file per
 * family per range covers every weight.
 *
 * THREE RANGES PER FAMILY, and this is not optional on a Nigerian product.
 * The first cut shipped only the `latin` subset, which does NOT contain:
 *
 *   • U+20A6 ₦ — the Naira sign, on every price in the product. It fell
 *     through to whatever font the device happened to have, so the one glyph
 *     every price starts with was drawn in a different typeface, at a
 *     different weight and width, from the digits beside it — and at 12px its
 *     crossbars sit at the same height as the tops of the digits, so the pair
 *     reads as struck through. It lives in `latin-ext`.
 *   • ọ ụ ẹ ị — the dot-below vowels Igbo and Yoruba names are written with.
 *     Google files these under `vietnamese`, not `latin-ext`, because that is
 *     the language its subsetter had in mind. A client called Ọlaide or a
 *     professional called Ụchenna had one letter of their name in a different
 *     font from the rest of it.
 *   • ń — Igbo n-acute, in `latin-ext`.
 *
 * So all three ranges ship, with the exact unicode-range values Google's own
 * split uses. The browser fetches a file only when a character in its range is
 * on the page: latin always, latin-ext as soon as a price is shown, vietnamese
 * only for a name that needs it. Give EVERY face an explicit range — a face
 * without one claims U+0-10FFFF, wins the match for ₦, has no glyph for it,
 * and the fallback happens all over again.
 *
 * ONE GLYPH IS STILL MISSING and subsetting cannot fix it: IBM Plex Sans has
 * no ṣ / Ṣ (U+1E63/U+1E62, Yoruba s-dot) in any published subset, IBM's own
 * build included. Archivo does. That is why every IBM Plex Sans stack in this
 * file names Archivo as its FIRST fallback: a Yoruba ṣ then renders in the
 * other family already on the page instead of an arbitrary system font.
 *
 * scripts/check-money-glyphs.js measures all of the above — it forces each
 * face to load and compares glyph advance widths, because document.fonts
 * .check() answers "would this family be used", not "did it load and does it
 * have this glyph", and returns true for a font whose file never arrived.
 *
 * Every rule that uses them still names a real fallback stack — a font that
 * fails to load must leave a legible page, not an invisible one.
 */
/* Vietnamese first: it is the narrowest range, and it is the one that carries
   ọ ụ ẹ ị for Igbo and Yoruba names. */
@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('/fonts/archivo-vietnamese-var.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
                 U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('/fonts/archivo-latin-ext-var.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('/fonts/archivo-latin-var.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 100 700;
  font-display: swap;
  src: url('/fonts/ibm-plex-sans-vietnamese-var.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
                 U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 100 700;
  font-display: swap;
  src: url('/fonts/ibm-plex-sans-latin-ext-var.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 100 700;
  font-display: swap;
  src: url('/fonts/ibm-plex-sans-latin-var.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ── Tokens ───────────────────────────────────────────────────────────────── */
:root {
  color-scheme: light;

  --bg: #F2F2F4;
  --bg-elevated: #FFFFFF;
  --surface: #FFFFFF;
  --surface-strong: #F1F1F3;
  --border: rgba(0, 0, 0, 0.07);
  --border-strong: rgba(0, 0, 0, 0.12);

  --text: #33333B;
  --text-dim: #6B6B72;
  --text-faint: #9A9AA2;

  --gold: #DFA828;
  --gold-deep: #B8860F;
  --gold-soft: rgba(223, 168, 40, 0.12);
  --gold-line: rgba(223, 168, 40, 0.28);
  --on-gold: #1A1A1A;

  --success: #1FA45C;
  --success-bg: rgba(31, 164, 92, 0.12);
  --danger: #D64545;
  --danger-bg: rgba(214, 69, 69, 0.10);
  --info: #2F7BE8;

  /* Job stage accents, matching the app's `colors.stage`. */
  --stage-assigned: #DFA828;
  --stage-en_route: #2F7BE8;
  --stage-arrived: #8B5CF6;
  --stage-in_progress: #0EA5E9;
  --stage-completed: #1FA45C;
  --stage-awaiting_payment: #DFA828;
  --stage-paid: #1FA45C;
  --stage-closed: #6B6B72;
  --stage-cancelled: #D64545;

  /* ── The ink surface ────────────────────────────────────────────────────
   * One deep surface, used for the ONE thing happening right now: a
   * professional on their way to your door. Everything else on these screens
   * is light and quiet so this reads as the only live thing on the page.
   *
   * It exists because the account read as flat: every card was a white
   * rounded rectangle on light grey, so a job in progress carried the same
   * weight as a link to the safety page. And gold spent as a large flat fill
   * reads cheap — the same gold, small and precise on near-black, reads
   * expensive. `--gold-on-ink` is the dark-theme gold (#F0D060), which is the
   * one tuned for black; the light-theme #DFA828 goes muddy there.
   */
  --ink: #17171C;
  --ink-2: #212128;
  --ink-line: rgba(255, 255, 255, 0.10);
  --on-ink: #F5F5F7;
  --on-ink-dim: rgba(245, 245, 247, 0.62);
  --on-ink-faint: rgba(245, 245, 247, 0.40);
  --gold-on-ink: #F0D060;
  --shadow-ink: 0 10px 30px rgba(23, 23, 28, 0.28);

  --shadow-card: 0 3px 12px rgba(0, 0, 0, 0.06);
  --shadow-pop: 0 12px 40px rgba(0, 0, 0, 0.16);

  /* ── THE MOTION SCALE ────────────────────────────────────────────────────
     Four steps, matching src/motion.ts in the app number for number, the same
     way --radius-* matches its radius scale. THE SIZE OF THE MOVE PICKS THE
     STEP:

       tap      90ms   an icon, a press state, a dot
       control  180ms  ONE control changing what it is: a chip selecting, a
                       switch throwing, a chevron turning
       surface  260ms  something with an EDGE moving: the tab bar leaving, a
                       sheet, a drawer, a banner arriving
       screen   320ms  a whole view replacing another

     A step up is visibly slower than the one below it, which is what makes the
     hierarchy legible: a sheet cannot be mistaken for a toggle.

     LEAVING IS FASTER THAN ARRIVING (`-out`, about 0.75). What is going away
     has already been read.

     This replaced nine durations chosen one at a time — .08 .1 .12 .15 .16
     .17 .18 .2 .26 — which is exactly the state the app was in before its own
     scale. No single one was wrong; the whole was chaotic, and two things the
     same size moved differently.

     `--ease` is the decelerating curve almost everything uses. Reduced motion
     is NOT handled here: it is handled where it belongs, in the
     `prefers-reduced-motion` blocks beside each rule, because what that
     setting asks us to drop is large positional movement rather than the fact
     that something changed. */
  --motion-tap: 90ms;
  --motion-control: 180ms;
  --motion-surface: 260ms;
  --motion-screen: 320ms;
  --motion-tap-out: 70ms;
  --motion-control-out: 140ms;
  --motion-surface-out: 200ms;
  --ease: cubic-bezier(.4, 0, .2, 1);

  --radius-sm: 10px;
  --radius-md: 14px;
  --radius-lg: 18px;
  --radius-xl: 24px;
  --radius-pill: 999px;

  --nav-h: 56px;
  /* The top bar's real height, used by EBUCHAT to size a conversation against
     the viewport. `.topbar` sets `min-height` from this token and everything is
     border-box, so the number cannot drift from the thing it measures — which
     it previously had: the old comment added up 12+26+12+1=51 and forgot that
     .topbar-brand carries min-height:40 to stay a tap target, so the bar was
     really 65px and every conversation was sized 14px too tall. */
  --topbar-h: 56px;
  --wrap: 620px;

  /* Type weight scale.
   *
   * Deliberately one notch lighter than a typical bold scale — the same move
   * the app made in src/theme.ts. At these sizes 700/800 everywhere reads as
   * shouting: the page looks heavy and nothing stands out, because when every
   * line is bold none of them is. Dropping a notch restores the hierarchy and
   * the surfaces read as premium rather than loud.
   *
   * Tune here, not at the call sites. */
  --fw-display: 600;   /* big screen-opening headings */
  --fw-heading: 600;   /* card titles, service names */
  --fw-label:   500;   /* row labels, tabs, segmented controls */
  --fw-medium:  600;   /* buttons, values, the emphasised line */
  --fw-caps:    600;   /* small uppercase eyebrows and group titles */
  --fw-figure:  700;   /* big numbers, codes and pills — these still need presence */

  /* ── Type scale ─────────────────────────────────────────────────────────
   *
   * TUNE THE SIZE OF THE WHOLE PORTAL HERE. Nothing below this block should
   * write a literal px font-size.
   *
   * This used to be 87 literal sizes across 23 magic numbers, which is how the
   * portal got asked to shrink twice: a pass would catch some call sites and
   * miss the rest, so the result was uneven rather than smaller. Tokens mean
   * one edit moves the set and nothing is left behind.
   *
   * Three floors are NOT negotiable, and they are expressed as their own
   * tokens so a future shrink cannot quietly cross them:
   *
   *   --fs-input  16px   less and iOS zooms the page the instant an input
   *                      takes focus, which throws the layout sideways
   *   --tap       40px   the height of anything you touch. A separate axis
   *                      from type: text can shrink, thumbs cannot.
   *   --fs-badge  9.5px  the smallest legible size on a cheap LCD outdoors
   *
   * Figures come down LESS than headings on purpose. An ID code, a payout
   * amount or an arrival code is what somebody squints at across a room or
   * reads out over a phone — those earn their size, a screen title does not. */
  --fs-display:  20px;   /* screen titles, auth headings */
  --fs-h1:       17px;   /* card + section headings */
  --fs-h2:       15px;   /* service names, tile values, empty states */
  --fs-h3:       16px;   /* sheet titles, ID-card name */
  --fs-body:     14px;   /* buttons, list rows */
  --fs-label:    13.5px; /* row labels, the emphasised line */
  --fs-sub:      13px;   /* timeline labels, key/value pairs */
  --fs-meta:     12.5px; /* secondary text, job meta, amounts in lists */
  --fs-small:    12px;   /* hints, field labels, tile labels */
  --fs-caps:     11px;   /* uppercase eyebrows and group titles */
  --fs-tiny:     10.5px; /* pills, counts */
  --fs-nav:      10px;   /* tab-bar labels */
  --fs-badge:    9.5px;  /* unread counts — the floor */

  /* Figures. */
  --fs-figure-xl: 26px;  /* the payout total */
  /* The one thing on the client's screen allowed to be bigger than a screen
   * title: how far away their professional is. It is the number they opened
   * the page for, and it sits on the ink card, not in the body copy — so it
   * does not reopen the "everything is too big" complaint that produced this
   * scale. Nothing else may use it. */
  --fs-live: 28px;
  --fs-figure-lg: 22px;  /* amount due */
  --fs-figure:    20px;  /* arrival / ID codes */

  /* Floors. */
  --fs-input: 16px;
  --tap: 40px;

  /* ── Spacing ────────────────────────────────────────────────────────────
   * The other half of "it feels too big". Type was shrunk once on its own and
   * the screens still read as oversized, because the padding around every card
   * and row had not moved. Same rule: tune here, not at the call sites. */
  --sp-wrap-y: 14px;   /* screen padding, top/bottom */
  --sp-wrap-x: 13px;   /* screen padding, sides */
  --sp-card:   13px;   /* inside a card */
  --sp-gap:    10px;   /* between cards */
  --sp-group:  14px;   /* between grouped-list sections */
  --sp-row-y:  11px;   /* inside a list row */
  --sp-row-x:  13px;
}

/* Dark, when the device asks for it and the worker has not forced light. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;

    --bg: #0C0C0D;
    --bg-elevated: #151517;
    --surface: #151517;
    --surface-strong: #1E1E22;
    --border: rgba(255, 255, 255, 0.09);
    --border-strong: rgba(255, 255, 255, 0.16);

    --text: #F2F2F4;
    --text-dim: #B9B9C2;
    --text-faint: #8A8A93;

    --gold: #F0D060;
    --gold-deep: #C09820;
    --gold-soft: rgba(240, 208, 96, 0.10);
    --gold-line: rgba(240, 208, 96, 0.26);
    --on-gold: #1A1A1A;

    --success: #6FD79B;
    --success-bg: rgba(111, 215, 155, 0.14);
    --danger: #FF8A80;
    --danger-bg: rgba(255, 138, 128, 0.14);
    --info: #7FB3F5;

    --stage-assigned: #F0D060;
    --stage-en_route: #7FB3F5;
    --stage-arrived: #B79BFF;
    --stage-in_progress: #67C7F0;
    --stage-completed: #6FD79B;
    --stage-awaiting_payment: #F0D060;
    --stage-paid: #6FD79B;
    --stage-closed: #9A9AA2;
    --stage-cancelled: #FF8A80;

    /* The ink surface LIFTS here rather than deepening. Its job is to be the
     * most distinct surface on the screen; against a #0C0C0D page a darker
     * card would vanish, so the role inverts even though the colour does not
     * flip. Same idea, opposite direction. */
    --ink: #24242D;
    --ink-2: #2E2E39;
    --ink-line: rgba(255, 255, 255, 0.13);
    --on-ink: #F5F5F7;
    --on-ink-dim: rgba(245, 245, 247, 0.64);
    --on-ink-faint: rgba(245, 245, 247, 0.42);
    --gold-on-ink: #F0D060;
    --shadow-ink: 0 10px 34px rgba(0, 0, 0, 0.6);

    --shadow-card: 0 3px 14px rgba(0, 0, 0, 0.5);
    --shadow-pop: 0 16px 48px rgba(0, 0, 0, 0.65);
  }
}

/* Dark, chosen explicitly — must also win on a light-preferring device. */
:root[data-theme="dark"] {
  color-scheme: dark;

  --bg: #0C0C0D;
  --bg-elevated: #151517;
  --surface: #151517;
  --surface-strong: #1E1E22;
  --border: rgba(255, 255, 255, 0.09);
  --border-strong: rgba(255, 255, 255, 0.16);

  --text: #F2F2F4;
  --text-dim: #B9B9C2;
  --text-faint: #8A8A93;

  --gold: #F0D060;
  --gold-deep: #C09820;
  --gold-soft: rgba(240, 208, 96, 0.10);
  --gold-line: rgba(240, 208, 96, 0.26);
  --on-gold: #1A1A1A;

  --success: #6FD79B;
  --success-bg: rgba(111, 215, 155, 0.14);
  --danger: #FF8A80;
  --danger-bg: rgba(255, 138, 128, 0.14);
  --info: #7FB3F5;

  --stage-assigned: #F0D060;
  --stage-en_route: #7FB3F5;
  --stage-arrived: #B79BFF;
  --stage-in_progress: #67C7F0;
  --stage-completed: #6FD79B;
  --stage-awaiting_payment: #F0D060;
  --stage-paid: #6FD79B;
  --stage-closed: #9A9AA2;
  --stage-cancelled: #FF8A80;

  /* Lifts, for the same reason as the media-query block above. */
  --ink: #24242D;
  --ink-2: #2E2E39;
  --ink-line: rgba(255, 255, 255, 0.13);
  --on-ink: #F5F5F7;
  --on-ink-dim: rgba(245, 245, 247, 0.64);
  --on-ink-faint: rgba(245, 245, 247, 0.42);
  --gold-on-ink: #F0D060;
  --shadow-ink: 0 10px 34px rgba(0, 0, 0, 0.6);

  --shadow-card: 0 3px 14px rgba(0, 0, 0, 0.5);
  --shadow-pop: 0 16px 48px rgba(0, 0, 0, 0.65);
}

/* ── Base ─────────────────────────────────────────────────────────────────── */
* { margin: 0; padding: 0; box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  min-height: 100vh;
  min-height: 100dvh;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  font-size: var(--fs-label);
  line-height: 1.4;
  -webkit-font-smoothing: antialiased;
  transition: background-color var(--motion-control) ease, color var(--motion-control) ease;
}

img { max-width: 100%; display: block; }
a { color: var(--gold-deep); }

:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 2px;
  border-radius: 6px;
}

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ── App shell ────────────────────────────────────────────────────────────── */
/* Mobile: a top bar and a bottom tab bar, exactly like the app.
   Desktop (>=900px): the tabs become a left rail and the content centres. */
.app {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100dvh;
}

.topbar {
  position: sticky; top: 0; z-index: 30;
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  min-height: var(--topbar-h);
  padding: 7px var(--sp-wrap-x);
  padding-top: calc(7px + env(safe-area-inset-top));
  /* `floating`: content passes under this, which is what earns the blur. */
  background: var(--s-floating);
  backdrop-filter: saturate(180%) blur(var(--blur-floating));
  -webkit-backdrop-filter: saturate(180%) blur(var(--blur-floating));
  border-bottom: 1px solid var(--e-chrome);
}
.topbar-brand {
  display: flex; align-items: center; gap: 9px; min-width: 0; text-decoration: none;
  /* The logo is 26px; without this the link is 26px tall to aim at. */
  min-height: var(--tap);
}
.topbar-brand img { width: 24px; height: 24px; object-fit: contain; }
.topbar-word {
  font-size: var(--fs-small); font-weight: var(--fw-figure); letter-spacing: 1.6px;
  color: var(--gold-deep); text-transform: uppercase;
}
.topbar-actions { display: flex; align-items: center; gap: 8px; }

.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; flex: 0 0 auto;
  border-radius: var(--radius-pill);
  background: var(--surface); border: 1px solid var(--border);
  color: var(--text-dim); cursor: pointer;
  font-size: var(--fs-h3); line-height: 1;
  transition: background-color var(--motion-control) ease, color var(--motion-control) ease, transform var(--motion-tap) ease;
}
.icon-btn:hover { color: var(--text); background: var(--surface-strong); }
.icon-btn:active { transform: scale(.94); }
.icon-btn svg { width: 18px; height: 18px; }

.main { flex: 1; width: 100%; }
.wrap {
  width: 100%; max-width: var(--wrap);
  margin: 0 auto;
  padding: var(--sp-wrap-y) var(--sp-wrap-x) calc(var(--nav-h) + 24px + env(safe-area-inset-bottom));
}

/* ── Bottom tabs (mobile) → left rail (desktop) ───────────────────────────── */
.tabs {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
  display: flex; align-items: stretch;
  height: calc(var(--nav-h) + env(safe-area-inset-bottom));
  padding-bottom: env(safe-area-inset-bottom);
  /* The other `floating` surface, and the other place the blur is paid for:
     a list scrolls under this bar on every screen that has one. */
  background: var(--s-floating);
  backdrop-filter: saturate(180%) blur(var(--blur-floating));
  -webkit-backdrop-filter: saturate(180%) blur(var(--blur-floating));
  border-top: 1px solid var(--e-chrome);
  box-shadow: var(--lift-floating);

  /* Slides out of the way while you read — autoHideTabs() in
     portal-chrome.js decides when. The easing is deliberately asymmetric to
     the eye: 260ms is long enough to read as a movement rather than a flicker,
     and the curve leaves quickly and arrives softly, which is what makes the
     thing feel like it got out of your way rather than fell off the screen. */
  transition: transform var(--motion-surface) var(--ease);
}
/* Off the bottom, not shrunk. `transform` composites on the GPU and moves
   nothing else on the page, so nothing under it reflows while it goes. */
body.tabs-hidden .tabs { transform: translateY(100%); }

/* Someone who has asked for less motion still gets the space back — it just
   arrives instead of sliding. Vestibular sensitivity is not a reason to keep
   56px of chrome they did not want. */
@media (prefers-reduced-motion: reduce) {
  .tabs { transition: none; }
}
.tab {
  flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 3px; border: none; background: none; cursor: pointer;
  color: var(--text-faint); font-family: inherit; font-size: var(--fs-nav); font-weight: var(--fw-label);
  letter-spacing: .2px; padding: 0; position: relative;
  transition: color var(--motion-control) ease;
}
.tab svg { width: 19px; height: 19px; }

/* The rail toggle is a desktop control — the phone has a tab bar and there is
   nothing to collapse. Without this it has no display rule at small widths, so
   it becomes a 4px flex sliver wedged into the left of the tab bar, stealing
   space from Home. */
.rail-toggle { display: none; }
.tab[aria-selected="true"] { color: var(--gold-deep); }
.tab-badge {
  position: absolute; top: 8px; left: 50%; margin-left: 5px;
  min-width: 16px; height: 16px; padding: 0 4px;
  border-radius: var(--radius-pill); background: var(--danger); color: #fff;
  font-size: var(--fs-badge); font-weight: var(--fw-figure); line-height: 16px; text-align: center;
}

/* ── Typography ───────────────────────────────────────────────────────────── */
.display { font-size: var(--fs-display); font-weight: var(--fw-display); letter-spacing: -.5px; line-height: 1.2; }
.h1 { font-size: var(--fs-h1); font-weight: var(--fw-heading); letter-spacing: -.4px; }
.h2 { font-size: var(--fs-h2); font-weight: 600; letter-spacing: -.25px; }
.small { font-size: var(--fs-meta); }
.dim { color: var(--text-dim); }
.faint { color: var(--text-faint); }
.eyebrow {
  font-size: var(--fs-caps); font-weight: var(--fw-caps); letter-spacing: .5px;
  text-transform: uppercase; color: var(--text-faint);
}
.screen-title { font-size: var(--fs-display); font-weight: var(--fw-display); letter-spacing: -.5px; margin-bottom: 11px; }

/* ── Cards ────────────────────────────────────────────────────────────────── */
.card {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--sp-card);
  margin-bottom: var(--sp-gap);
  box-shadow: var(--shadow-card);
}
.card-gold { background: var(--gold-soft); border-color: var(--gold-line); }

/* ── Buttons ──────────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  width: 100%; min-height: var(--tap); padding: 10px 16px;
  border: 1px solid transparent; border-radius: var(--radius-md);
  background: var(--gold); color: var(--on-gold);
  font-family: inherit; font-size: var(--fs-body); font-weight: var(--fw-medium);
  cursor: pointer; text-decoration: none;
  transition: filter var(--motion-control) ease, transform var(--motion-tap) ease, background-color var(--motion-control) ease;
}
.btn:hover { filter: brightness(1.04); }
.btn:active { transform: scale(.985); }
.btn:disabled { opacity: .55; cursor: default; transform: none; filter: none; }
.btn svg { width: 16px; height: 16px; }

.btn-quiet {
  background: var(--surface); color: var(--text); border-color: var(--border-strong);
}
.btn-quiet:hover { background: var(--surface-strong); }

/* Declining loses the job, so it must never look like the primary action. */
.btn-ghost {
  background: transparent; color: var(--text-dim);
  border-color: var(--border-strong); font-weight: var(--fw-label);
}
.btn-ghost:hover { color: var(--danger); border-color: var(--danger); background: var(--danger-bg); }

.btn-sm { width: auto; min-height: var(--tap); padding: 8px 14px; font-size: var(--fs-meta); }

/* ── Forms ────────────────────────────────────────────────────────────────── */
/* 16px minimum: anything smaller makes iOS zoom the page on focus. */
.field {
  width: 100%; min-height: var(--tap); padding: 10px 13px;
  background: var(--surface-strong);
  border: 1px solid var(--border-strong); border-radius: var(--radius-md);
  color: var(--text); font-family: inherit; font-size: var(--fs-input);
  transition: border-color var(--motion-control) ease, background-color var(--motion-control) ease;
}
.field::placeholder { color: var(--text-faint); }
.field:focus { outline: none; border-color: var(--gold); background: var(--surface); }
.field-label {
  display: block; font-size: var(--fs-small); font-weight: var(--fw-caps);
  color: var(--text-dim); margin-bottom: 7px;
}
/* A consent tick. The whole row is the target, not the box: an 18px square is
   a thing you miss, and "I did not agree to that" is an expensive thing for a
   user to be wrong about. */
.consent {
  display: flex; gap: 10px; align-items: flex-start;
  min-height: var(--tap); margin-top: 13px; padding: 9px 0;
  cursor: pointer;
}
.consent input[type="checkbox"] {
  flex: 0 0 auto; width: 20px; height: 20px; margin-top: 1px;
  accent-color: var(--gold); cursor: pointer;
}
.consent-text {
  font-size: var(--fs-small); color: var(--text-dim);
  line-height: 1.45; white-space: normal;
}

/* An inline link inside a sentence cannot be 40px tall without shoving the
   line apart. So the HIT AREA grows and the layout does not: padding pushes
   the touchable box out, an equal negative margin pulls the flow back. A
   worker tapping a client's number on a bumpy road gets a real target, and
   the row stays the height it looks. */
.val a[href^="tel:"], .row-value a[href^="tel:"], a.tel-link {
  display: inline-block;
  padding: 13px 4px; margin: -13px -4px;
  text-decoration: none; color: var(--gold-deep); font-weight: var(--fw-medium);
}
.val a[href^="tel:"]:hover, .row-value a[href^="tel:"]:hover, a.tel-link:hover {
  text-decoration: underline;
}

.inline-form { display: flex; gap: 8px; align-items: stretch; }
.inline-form .field { flex: 1; min-width: 0; }
.inline-form .btn { width: auto; flex: 0 0 auto; }

/* ── Rows / grouped lists (the app's settings pattern) ────────────────────── */
.group { margin-bottom: var(--sp-group); }
.group-title {
  font-size: var(--fs-caps); font-weight: var(--fw-caps); letter-spacing: .5px; text-transform: uppercase;
  color: var(--text-faint); margin: 0 0 7px 4px;
}
.group-body {
  background: var(--bg-elevated); border: 1px solid var(--border);
  border-radius: var(--radius-lg); overflow: hidden;
}
.row {
  display: flex; align-items: center; gap: 11px;
  width: 100%; min-height: 44px; padding: var(--sp-row-y) var(--sp-row-x); text-align: left;
  background: none; border: none; border-bottom: 1px solid var(--border);
  color: var(--text); font-family: inherit; font-size: var(--fs-body);
  cursor: pointer; text-decoration: none;
  transition: background-color var(--motion-tap) ease;
}
.row:last-child { border-bottom: none; }
.row:hover { background: var(--surface-strong); }
.row-static { cursor: default; }
.row-static:hover { background: none; }
.row-icon {
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; border-radius: var(--radius-pill);
  background: var(--surface-strong); color: var(--text-dim);
}
.row-icon svg { width: 17px; height: 17px; }
.row-icon-gold { background: var(--gold-soft); color: var(--gold-deep); }
.row-body { flex: 1; min-width: 0; }
.row-label { display: block; font-size: var(--fs-label); font-weight: var(--fw-label); }
.row-hint {
  display: block;
  font-size: var(--fs-small); color: var(--text-dim); margin-top: 1px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.row-value {
  font-size: var(--fs-meta); font-weight: var(--fw-medium); color: var(--text-dim);
  max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.row-chev { flex: 0 0 auto; color: var(--text-faint); }
.row-chev svg { width: 17px; height: 17px; }
.row-danger, .row-danger .row-label { color: var(--danger); }

/* ── Switch ───────────────────────────────────────────────────────────────── */
/* A real checkbox, visually hidden but still focusable and announced; the
   track and thumb are drawn from it so keyboard and screen-reader users get
   the control for free. */
.switch { position: relative; flex: 0 0 auto; display: inline-flex; }
.switch input {
  position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer;
  width: 100%; height: 100%; z-index: 1;
}
.switch-track {
  width: 46px; height: 28px; border-radius: var(--radius-pill);
  background: var(--border-strong); display: inline-flex; align-items: center;
  padding: 3px; transition: background-color var(--motion-control) ease;
}
.switch-thumb {
  width: 22px; height: 22px; border-radius: 50%; background: #fff;
  box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
  transition: transform var(--motion-control) cubic-bezier(.2, .8, .2, 1);
}
.switch input:checked + .switch-track { background: var(--gold); }
.switch input:checked + .switch-track .switch-thumb { transform: translateX(18px); }
.switch input:disabled + .switch-track { opacity: .55; }
.switch input:focus-visible + .switch-track { outline: 2px solid var(--gold); outline-offset: 2px; }

/* ── Avatar ───────────────────────────────────────────────────────────────── */
.avatar {
  border-radius: var(--radius-pill); background: var(--surface-strong);
  object-fit: cover; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--gold-deep); font-weight: var(--fw-figure);
  border: 1px solid var(--border);
  overflow: hidden;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.avatar-sm { width: 38px; height: 38px; font-size: var(--fs-meta); }
.avatar-md { width: 42px; height: 42px; font-size: var(--fs-sub); }
.avatar-lg { width: 76px; height: 76px; font-size: var(--fs-figure-lg); }

.avatar-edit { position: relative; display: inline-block; cursor: pointer; }
.avatar-edit-badge {
  position: absolute; right: -1px; bottom: -1px;
  width: 26px; height: 26px; border-radius: var(--radius-pill);
  background: var(--gold); color: var(--on-gold);
  display: flex; align-items: center; justify-content: center;
  border: 2.5px solid var(--bg);
}
.avatar-edit-badge svg { width: 13px; height: 13px; }

/* A small copy button beside a value worth copying — an account number, a
   code. Sized to the tap floor even though it looks smaller than that: the
   icon is 15px and the target around it is 40. */
.btn-copy {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--tap); height: var(--tap); margin: -10px -8px -10px 2px;
  vertical-align: middle;
  background: none; border: none; color: var(--text-faint); cursor: pointer;
  border-radius: var(--radius-sm);
}
.btn-copy:hover { color: var(--gold-deep); background: var(--surface-strong); }
.btn-copy svg { width: 15px; height: 15px; }

/* The code a client reads out loud. Deliberately unlike `.verify-code` — the
   worker's EBUTECH ID sits on the same screen, and the two are opposite jobs:
   one you check, one you give. */
.code-digits {
  display: flex; justify-content: center; gap: 6px; margin-top: 4px;
}
.code-digit {
  flex: 0 0 auto;
  min-width: 38px; padding: 10px 0;
  border-radius: var(--radius-md);
  background: var(--bg-elevated); border: 1px solid var(--gold-line);
  color: var(--gold-deep);
  font-size: var(--fs-figure); font-weight: var(--fw-figure);
  text-align: center; line-height: 1;
  /* Tabular so the boxes do not shimmy as digits change width. */
  font-variant-numeric: tabular-nums;
}
@media (max-width: 359px) {
  /* Six 38px boxes plus gaps do not fit a 360px screen once the card padding
     is taken off. */
  .code-digit { min-width: 32px; font-size: var(--fs-h1); }
  .code-digits { gap: 4px; }
}

/* The rating stars. */
.rate-stars { display: flex; gap: 4px; margin-bottom: 10px; }
.rate-star {
  width: var(--tap); height: var(--tap);
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: none; cursor: pointer;
  color: var(--border-strong);
  transition: color var(--motion-tap) ease, transform var(--motion-tap) ease;
}
.rate-star svg { width: 26px; height: 26px; }
.rate-star:active { transform: scale(.92); }
.rate-star.is-on { color: var(--gold); }
.rate-star.is-on svg { fill: currentColor; }

/* ── Pills / badges ───────────────────────────────────────────────────────── */
.pill {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 4px 10px; border-radius: var(--radius-pill);
  /* A .pill is usually a label. Where it is a button — the removable chips on
     the booking form — 20px tall is not something a finger hits. */
  font-size: var(--fs-tiny); font-weight: var(--fw-figure); letter-spacing: .3px; text-transform: uppercase;
  white-space: nowrap;
}
/* A .pill used as a BUTTON has to bring its own surface. Without one the
   browser's default `background: buttonface` shows through, and the saved
   address chips on the booking form rendered as raw grey UA buttons in the
   middle of a designed form — a 40px min-height around 10px uppercase text
   made them grey circles. The tap floor stays; the padding widens so the chip
   is a chip and not a coin. */
button.pill {
  min-height: var(--tap); padding: 0 14px; cursor: pointer;
  background: var(--surface-strong); color: var(--text);
  border: 1px solid var(--border);
}
button.pill:hover { border-color: var(--gold-line); }
.pill-gold, button.pill-gold { background: var(--gold); color: var(--on-gold); border-color: var(--gold); }
.pill-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* Stage colour comes in as --stage-c on the element's own style attribute, so
   one rule covers every stage.
   NO ATTRIBUTE SELECTOR. This was `.pill-stage[data-stage]`, and the customer
   account's stagePill() sets --stage-c but never sets data-stage — only the
   worker portal's does. So the selector missed every pill in the account and
   the whole lifecycle rendered in the same grey: en route, at your door and
   paid were visually identical on the bookings list and the detail screen. The
   fallback does the guarding the attribute was there for — a pill with no
   --stage-c still reads as a neutral chip instead of an invalid colour. */
.pill-stage {
  color: var(--stage-c, var(--text-dim));
  background: color-mix(in srgb, var(--stage-c, var(--text-dim)) 14%, transparent);
}

/* ── Tiles / stats ────────────────────────────────────────────────────────── */
.tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.tile {
  background: var(--bg-elevated); border: 1px solid var(--border);
  border-radius: var(--radius-lg); padding: 16px 12px;
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  text-align: center;
}
.tile-icon { color: var(--gold); }
.tile-icon svg { width: 17px; height: 17px; }
.tile-value { font-size: var(--fs-h2); font-weight: var(--fw-medium); letter-spacing: -.3px; }
.tile-label { font-size: var(--fs-small); color: var(--text-faint); }

/* ── Job card ─────────────────────────────────────────────────────────────── */
.job-head { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; margin-bottom: 8px; }
.job-service { font-size: var(--fs-h2); font-weight: var(--fw-heading); letter-spacing: -.2px; }
.job-meta { font-size: var(--fs-meta); color: var(--text-dim); margin-top: 2px; }
.job-rows { border-top: 1px solid var(--border); padding-top: 9px; margin: 9px 0; }
.job-row { display: flex; gap: 10px; font-size: var(--fs-sub); padding: 2px 0; color: var(--text); }
.job-row .lbl { flex: 0 0 84px; color: var(--text-faint); }
.job-row .val { min-width: 0; overflow-wrap: anywhere; }
.job-actions { display: flex; flex-direction: column; gap: 7px; }
.hint { font-size: var(--fs-small); color: var(--text-dim); line-height: 1.45; }
.hint-ok { color: var(--success); font-weight: 600; }
.job-msg { font-size: var(--fs-small); margin-top: 8px; min-height: 16px; }
.job-msg.error { color: var(--danger); }
.job-msg.success { color: var(--success); }

/* ── Empty / loading states ───────────────────────────────────────────────── */
.empty { text-align: center; padding: 44px 22px; }
.empty-circle {
  width: 64px; height: 64px; border-radius: 50%; margin: 0 auto 14px;
  background: var(--surface-strong); color: var(--text-faint);
  display: flex; align-items: center; justify-content: center;
}
.empty-circle svg { width: 26px; height: 26px; }
.empty-title { font-size: var(--fs-h2); font-weight: 600; letter-spacing: -.25px; }
.empty-text { font-size: var(--fs-meta); color: var(--text-dim); margin-top: 5px; line-height: 1.5; }

.skeleton {
  background: linear-gradient(90deg, var(--surface-strong) 25%, var(--border) 37%, var(--surface-strong) 63%);
  background-size: 400% 100%;
  animation: shimmer 1.3s ease-in-out infinite;
  border-radius: var(--radius-md);
}
@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
.skeleton-card { height: 132px; margin-bottom: 12px; border-radius: var(--radius-lg); }

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

/* ── Toast ────────────────────────────────────────────────────────────────── */
.toast-host {
  position: fixed; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--nav-h) + 16px + env(safe-area-inset-bottom));
  z-index: 60; width: calc(100% - 32px); max-width: 420px;
  display: flex; flex-direction: column; gap: 8px; pointer-events: none;
}
.toast {
  background: var(--bg-elevated); border: 1px solid var(--border-strong);
  border-radius: var(--radius-md); padding: 12px 15px;
  box-shadow: var(--shadow-pop); font-size: var(--fs-body); font-weight: 500;
  animation: toast-in .22s ease;
}
.toast-error { border-color: var(--danger); color: var(--danger); }
.toast-ok { border-color: var(--success); color: var(--success); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

/* ── Sheet / modal ────────────────────────────────────────────────────────── */
.sheet-backdrop {
  position: fixed; inset: 0; z-index: 70;
  background: rgba(0, 0, 0, 0.5);
  display: flex; align-items: flex-end; justify-content: center;
  animation: fade-in .18s ease;
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
.sheet {
  width: 100%; max-width: 520px;
  background: var(--bg); color: var(--text);
  border-radius: var(--radius-xl) var(--radius-xl) 0 0;
  padding: 20px 18px calc(22px + env(safe-area-inset-bottom));
  max-height: 88vh; overflow-y: auto;
  animation: sheet-up .24s cubic-bezier(.2, .8, .2, 1);
}
@keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } }
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.sheet-title { font-size: var(--fs-h3); font-weight: 600; letter-spacing: -.25px; }
.sheet-actions { display: flex; flex-direction: column; gap: 9px; margin-top: 16px; }

/* ── EBUTECH ID card ──────────────────────────────────────────────────────── */
.idcard {
  background: var(--gold-soft); border: 1px solid var(--gold-line);
  border-radius: var(--radius-lg); padding: 26px 22px; text-align: center;
}
.idcard-logo {
  width: 52px; height: 52px; border-radius: var(--radius-md);
  background: #1E1E22; margin: 0 auto 8px;
  display: flex; align-items: center; justify-content: center;
}
.idcard-logo img { width: 38px; height: 38px; object-fit: contain; }
.idcard-word { font-size: var(--fs-small); font-weight: var(--fw-figure); letter-spacing: 1.6px; color: var(--gold-deep); }
.idcard-photo { margin: 16px auto 0; width: 70px; height: 70px; font-size: var(--fs-h1); }
.idcard-name { font-size: var(--fs-h3); font-weight: 600; margin-top: 12px; }
.idcard-role { font-size: var(--fs-meta); color: var(--text-dim); margin-top: 2px; }
.idcard-code-label {
  font-size: var(--fs-caps); font-weight: var(--fw-caps); letter-spacing: .5px;
  text-transform: uppercase; color: var(--gold-deep); margin-top: 22px;
}
.idcard-code {
  font-size: var(--fs-figure-xl); font-weight: var(--fw-figure); letter-spacing: 4px;
  margin-top: 8px; word-break: break-all;
}
.idcard-hint { font-size: var(--fs-meta); color: var(--text-dim); margin-top: 16px; line-height: 1.5; }

.notice {
  display: flex; gap: 12px; align-items: flex-start;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-md); padding: 14px; margin-top: 14px;
}
.notice-icon { flex: 0 0 auto; color: var(--gold); }
.notice-icon svg { width: 18px; height: 18px; }
.notice-text { font-size: var(--fs-meta); color: var(--text-dim); line-height: 1.5; }

/* ── Payout account ───────────────────────────────────────────────────────
   Where the worker's money lands. The confirmation block is the only loud
   thing here on purpose: it is the moment someone checks that a stranger is
   not about to be paid instead of them, and it should not read as a caption
   under a text field. No amounts appear anywhere on this screen. */
.btn-block { display: block; width: 100%; }

/* A settings row that is asking for something, rather than listing it. Used
   for "set up where you get paid" — earnings with nowhere to go is the one
   thing on the Earnings screen a worker has to act on. */
.row-prompt {
  border: 1px solid var(--gold-line);
  border-radius: var(--radius-md);
  background: var(--gold-soft);
  margin-bottom: 14px;
}

.py-intro { text-align: center; padding: 22px 8px 26px; }
.py-intro-icon {
  width: 54px; height: 54px; margin: 0 auto 14px; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--gold-soft); color: var(--gold-deep);
}
.py-intro-icon svg { width: 24px; height: 24px; }
.py-intro-title { font-size: var(--fs-h2, 18px); font-weight: var(--fw-bold, 700); }
.py-intro-text {
  margin: 8px auto 0; max-width: 34ch;
  font-size: var(--fs-meta); color: var(--text-dim); line-height: 1.55;
}

/* The account on file, shown as a card because that is what it is. */
.py-card {
  background: var(--bg-elevated); border: 1px solid var(--gold-line);
  border-radius: var(--radius-lg); padding: 20px;
}
.py-card-bank {
  font-size: var(--fs-meta); font-weight: var(--fw-medium); color: var(--text-dim);
  text-transform: uppercase; letter-spacing: .06em;
}
.py-card-acct {
  margin-top: 8px; font-size: 21px; font-weight: var(--fw-bold, 700);
  letter-spacing: .08em; font-variant-numeric: tabular-nums;
}
.py-card-name { margin-top: 6px; font-size: var(--fs-small); color: var(--text-dim); }
.py-card-status {
  margin-top: 12px; font-size: var(--fs-meta); font-weight: var(--fw-medium);
  text-transform: uppercase; letter-spacing: .06em;
}

/* The confirmation. The single most important thing on the screen. */
.py-confirm {
  margin-top: 16px; padding: 16px;
  background: var(--gold-soft); border: 1px solid var(--gold-line);
  border-radius: var(--radius-md); text-align: center;
}
.py-confirm-label {
  font-size: var(--fs-meta); color: var(--text-dim);
  text-transform: uppercase; letter-spacing: .06em;
}
.py-confirm-name {
  margin-top: 6px; font-size: 18px; font-weight: var(--fw-bold, 700); line-height: 1.3;
}
.py-confirm-ok {
  margin-top: 8px; font-size: var(--fs-meta); color: var(--success);
  display: inline-flex; align-items: center; gap: 6px;
}
.py-confirm-ok svg { width: 15px; height: 15px; }
.py-confirm-warn {
  margin-top: 8px; font-size: var(--fs-meta); color: var(--text-dim); line-height: 1.5;
}
.py-err {
  margin-top: 10px; font-size: var(--fs-meta); color: var(--danger, #c62828); line-height: 1.5;
}

/* ── Earnings ─────────────────────────────────────────────────────────────── */
.earn-hero {
  background:
    radial-gradient(circle at 12% 12%, var(--gold-soft) 0%, transparent 55%),
    var(--bg-elevated);
  border: 1px solid var(--gold-line); border-radius: var(--radius-lg);
  padding: 22px; margin-bottom: 14px;
}
/* The estimated next payout on the worker's home screen. */
.payout-figure {
  display: block; margin-top: 2px;
  font-size: var(--fs-figure-lg); font-weight: var(--fw-figure); color: var(--gold-deep);
}
.earn-amount { font-size: var(--fs-figure-xl); font-weight: var(--fw-figure); letter-spacing: -1px; margin: 4px 0 2px; }
.earn-sub { font-size: var(--fs-meta); color: var(--text-dim); }
.earn-processing {
  display: inline-block; margin-top: 12px; padding: 6px 13px;
  border-radius: var(--radius-pill); background: var(--gold); color: var(--on-gold);
  font-size: var(--fs-small); font-weight: var(--fw-medium);
}
.payout-row {
  display: flex; justify-content: space-between; align-items: center; gap: 12px;
  padding: 12px 0; border-top: 1px solid var(--border); font-size: var(--fs-sub);
}
.payout-row:first-child { border-top: none; }
.payout-amt { font-weight: var(--fw-medium); }

/* ── Scanner overlay ──────────────────────────────────────────────────────── */
.scan-overlay {
  position: fixed; inset: 0; z-index: 90; background: #000;
  display: flex; align-items: center; justify-content: center;
}
.scan-overlay video { width: 100%; height: 100%; object-fit: cover; }
.scan-frame {
  position: absolute; width: 226px; height: 226px;
  border: 3px solid var(--gold); border-radius: var(--radius-lg);
  box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .45);
}
.scan-tip {
  position: absolute; bottom: 122px; color: #fff; font-size: var(--fs-body); font-weight: 600;
  text-shadow: 0 1px 6px rgba(0, 0, 0, .85); padding: 0 24px; text-align: center;
}
.scan-close {
  position: absolute; bottom: 46px;
  background: rgba(255, 255, 255, .14); border: 1px solid rgba(255, 255, 255, .3);
  color: #fff; font-family: inherit; font-size: var(--fs-body); font-weight: var(--fw-medium);
  padding: 12px 28px; border-radius: var(--radius-pill); cursor: pointer;
}

/* ── Sign-in page ─────────────────────────────────────────────────────────── */
.auth-page {
  min-height: 100vh; min-height: 100dvh;
  display: flex; align-items: center; justify-content: center;
  padding: 24px 18px calc(24px + env(safe-area-inset-bottom));
}
.auth-card { width: 100%; max-width: 400px; }
.auth-brand { text-align: center; margin-bottom: 26px; }
.auth-brand img { width: 56px; height: 56px; margin: 0 auto 12px; object-fit: contain; }
.auth-title { font-size: var(--fs-display); font-weight: var(--fw-heading); letter-spacing: -.5px; }
.auth-sub { font-size: var(--fs-sub); color: var(--text-dim); margin-top: 5px; }
.auth-fields { display: flex; flex-direction: column; gap: 14px; }
.auth-msg { font-size: var(--fs-sub); margin-top: 14px; min-height: 18px; text-align: center; }
.auth-msg.error { color: var(--danger); }
.auth-msg.success { color: var(--success); }
.auth-foot { text-align: center; font-size: var(--fs-meta); color: var(--text-dim); margin-top: 20px; line-height: 1.6; }
.auth-theme { position: fixed; top: calc(14px + env(safe-area-inset-top)); right: 16px; z-index: 10; }

/* ── Desktop: tabs become a left rail ─────────────────────────────────────── */
@media (min-width: 900px) {
  .app { flex-direction: row; }

  .tabs {
    position: sticky; top: 0; bottom: auto; left: auto; right: auto;
    flex-direction: column; align-items: stretch; justify-content: flex-start;
    width: 232px; flex: 0 0 232px; height: 100vh; height: 100dvh;
    padding: 84px 14px 18px; gap: 4px;
    border-top: none; border-right: 1px solid var(--border);
    background: var(--bg-elevated); backdrop-filter: none; -webkit-backdrop-filter: none;
  }
  .tab {
    flex: 0 0 auto; flex-direction: row; justify-content: flex-start; gap: 13px;
    padding: 12px 14px; border-radius: var(--radius-md); font-size: var(--fs-label);
  }
  .tab:hover { background: var(--surface-strong); color: var(--text); }
  .tab[aria-selected="true"] { background: var(--gold-soft); color: var(--gold-deep); }
  .tab-badge { position: static; margin: 0 0 0 auto; }

  .shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
  .topbar {
    position: fixed; top: 0; left: 0; right: 0; z-index: 50;
    padding-left: 20px; padding-right: 20px;
  }
  .main { padding-top: 64px; }
  .wrap { padding-bottom: 48px; }
  .toast-host { bottom: 24px; left: auto; right: 24px; transform: none; }

  /* ── The rail collapses ──
     A left rail is the right shape for a desktop, but 232px of it is a lot to
     hold open for three destinations you already know the icons of. Collapsing
     is one click and the choice is remembered, so it is a preference rather
     than something to redo every visit. */
  .rail-toggle {
    display: inline-flex; align-items: center; justify-content: center;
    width: 40px; height: 40px; margin: 0 0 10px auto; flex: 0 0 auto;
    border-radius: var(--radius-md);
    background: none; border: 1px solid transparent;
    color: var(--text-faint); cursor: pointer;
    transition: background-color var(--motion-control) ease, color var(--motion-control) ease;
  }
  .rail-toggle:hover { background: var(--surface-strong); color: var(--text); }
  .rail-toggle svg { width: 18px; height: 18px; }

  /* The rail is persistent navigation and never hides — belt and braces
     against a tablet rotated from portrait, where the bar may already be
     translated off-screen when the layout becomes a rail. */
  body.tabs-hidden .tabs { transform: none; }
  .tabs { transition: width var(--motion-control) ease, flex-basis var(--motion-control) ease; }

  :root[data-rail="mini"] .tabs {
    width: 74px; flex: 0 0 74px; padding-left: 12px; padding-right: 12px;
  }
  :root[data-rail="mini"] .rail-toggle { margin-right: auto; margin-left: auto; }
  :root[data-rail="mini"] .tab { justify-content: center; padding: 12px 0; gap: 0; }
  /* The label goes, the icon stays put. `title` carries the name for anyone
     who needs it, and the tab keeps its accessible name from the span. */
  :root[data-rail="mini"] .tab > span:not(.tab-badge) {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap;
  }
  :root[data-rail="mini"] .tab-badge {
    position: absolute; top: 4px; right: 12px; margin: 0;
  }


  .sheet-backdrop { align-items: center; }
  .sheet { border-radius: var(--radius-xl); max-height: 84vh; }
}

/* ── Customer account ─────────────────────────────────────────────────────── */
/* Used by /account. Everything above is shared with the worker portal; these
   are the pieces only the customer side needs. */

/* Hide the topbar's inline CTA where there is no room for it. */
@media (max-width: 480px) { .hide-sm { display: none; } }

/* Detail views push over a list, so they carry their own back button. */
.detail-head {
  display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
}

/* Segmented control: Active / Past. */
.seg {
  display: flex; gap: 4px; padding: 4px; margin-bottom: 14px;
  background: var(--surface-strong); border-radius: var(--radius-md);
}
.seg-btn {
  flex: 1; min-height: var(--tap); padding: 8px 10px; border: none; border-radius: var(--radius-sm);
  background: none; color: var(--text-dim);
  font-family: inherit; font-size: var(--fs-label); font-weight: var(--fw-label); cursor: pointer;
  transition: background-color var(--motion-control) ease, color var(--motion-control) ease;
}
.seg-btn.is-on { background: var(--bg-elevated); color: var(--text); box-shadow: var(--shadow-card); }
.seg-count {
  display: inline-block; margin-left: 4px; padding: 0 6px;
  border-radius: var(--radius-pill); background: var(--gold-soft);
  color: var(--gold-deep); font-size: var(--fs-small); font-weight: var(--fw-figure);
}

/* Booking list card — a button, so the whole row is one tap target. */
.booking-card {
  display: block; width: 100%; text-align: left;
  font-family: inherit; color: inherit; cursor: pointer;
}
.booking-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; margin-top: 12px; padding-top: 12px;
  border-top: 1px solid var(--border);
}

/* Journey timeline on a booking. */
.tl-row { display: flex; gap: 12px; align-items: flex-start; }
.tl-rail { display: flex; flex-direction: column; align-items: center; flex: 0 0 auto; width: 14px; }
.tl-dot {
  width: 11px; height: 11px; border-radius: 50%; margin-top: 4px;
  background: var(--surface-strong); border: 2px solid var(--border-strong);
}
.tl-line { flex: 1; width: 2px; min-height: 22px; background: var(--border); }
.tl-label { font-size: var(--fs-sub); color: var(--text-faint); padding-bottom: 14px; }
.tl-row.is-done .tl-dot { background: var(--success); border-color: var(--success); }
.tl-row.is-done .tl-line { background: var(--success); }
.tl-row.is-done .tl-label { color: var(--text-dim); }
.tl-row.is-now .tl-dot {
  background: var(--gold); border-color: var(--gold);
  box-shadow: 0 0 0 4px var(--gold-soft);
}
.tl-row.is-now .tl-label { color: var(--text); font-weight: var(--fw-medium); }
/* The distance/ETA, hung under the step it belongs to. `block` rather than an
   inline span, so a long line ("About 24 min away · set off 09:12") wraps under
   the label instead of pushing the row wide on a 360px phone. */
.tl-eta {
  display: block; margin-top: 2px;
  font-size: var(--fs-small); font-weight: var(--fw-regular); color: var(--gold);
}

/* The assigned professional. */
.worker-row { display: flex; align-items: center; gap: 14px; }
.worker-photo { width: 50px; height: 50px; font-size: var(--fs-h2); }
.verify-box {
  margin-top: 14px; padding: 14px; text-align: center;
  background: var(--gold-soft); border: 1px solid var(--gold-line);
  border-radius: var(--radius-md);
}
.verify-code {
  font-size: var(--fs-figure); font-weight: var(--fw-figure); letter-spacing: 3px; margin: 4px 0 6px;
  word-break: break-all;
}

.pay-amount { font-size: var(--fs-figure-lg); font-weight: var(--fw-figure); letter-spacing: -.6px; }

/* Inline icon sitting in a line of text. */
.inline-ic { width: 14px; height: 14px; vertical-align: -2px; }

/* Forms inside cards stack rather than sit on one line. */
#profileForm .field, #addrForm .field { width: 100%; }

/* ── EBUCHAT ─────────────────────────────────────────────────────────────────
   A client and their professional talking about one job. Shared by the account
   and the worker portal, because two people reading one conversation should be
   reading the same thing.

   Deliberately not styled as a live messenger: no typing dots, no read
   receipts. This is a job thread that may run for weeks — a "seen" mark
   invites arguments about response times, and a typing indicator promises a
   presence nobody agreed to. */

/* ── The inbox ── */
.chat-list { display: flex; flex-direction: column; }
.chat-row {
  display: flex; align-items: center; gap: 12px;
  width: 100%; padding: 13px 4px; text-align: left;
  background: none; border: none; border-bottom: 1px solid var(--border);
  font-family: inherit; color: inherit; cursor: pointer;
}
.chat-row:last-child { border-bottom: none; }
.chat-row:hover { background: var(--surface); }
/* Which conversation is open. Only meaningful beside the thread, so only
   desktop paints it — on a phone the list is not on screen at the time. */
.chat-row.is-open { background: transparent; }

.chat-avatar {
  position: relative; flex: 0 0 auto; overflow: hidden;
  width: 46px; height: 46px; border-radius: 50%;
  background: var(--gold-soft); color: var(--gold-deep);
  display: inline-flex; align-items: center; justify-content: center;
  font-size: var(--fs-body); font-weight: var(--fw-medium);
}
/* The photo sits over the initials rather than replacing them, so a broken or
   slow image degrades to initials instead of an empty circle. JS removes the
   img on error; until then the initials are what shows through. */
.chat-avatar img {
  position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover;
}
.chat-avatar-sm { width: 38px; height: 38px; font-size: var(--fs-meta); }
/* A dot as well as the count, so an unread row reads as unread to someone who
   scans left to right and stops. */
.chat-dot {
  position: absolute; top: 0; right: 0; z-index: 2;
  width: 12px; height: 12px; border-radius: 50%;
  background: var(--gold); border: 2px solid var(--bg);
}

.chat-main { flex: 1; min-width: 0; }
.chat-top { display: flex; align-items: center; gap: 8px; }
.chat-name {
  flex: 1; min-width: 0; font-size: var(--fs-label); font-weight: var(--fw-label);
  color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chat-name.is-unread { font-weight: var(--fw-figure); }
.chat-when { flex: 0 0 auto; font-size: var(--fs-caps); color: var(--text-faint); }

.chat-preview {
  display: block; margin-top: 2px;
  font-size: var(--fs-sub); color: var(--text-dim);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chat-preview.is-unread { color: var(--text); font-weight: var(--fw-label); }

.chat-bottom { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.chat-service {
  flex: 1; min-width: 0; font-size: var(--fs-caps); color: var(--text-faint);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chat-closed { font-size: var(--fs-nav); font-weight: var(--fw-medium); color: var(--text-faint); }
.chat-badge {
  flex: 0 0 auto; min-width: 20px; height: 20px; padding: 0 6px;
  border-radius: 10px; background: var(--gold); color: var(--on-gold);
  display: inline-flex; align-items: center; justify-content: center;
  font-size: var(--fs-tiny); font-weight: var(--fw-figure);
}

/* ── The shell: one pane on a phone, two on a desktop ────────────────────────
   Both panes are always in the markup and CSS decides which are on screen.
   The alternative — asking JS which layout we are in — means a resize across
   the breakpoint renders the wrong thing until something re-renders, and a
   phone rotated to landscape is exactly that resize. */
.chat-pane { display: block; }
/* One pane at a time on a phone, so there is nothing to divide. */
.chat-grip { display: none; }
.chat-pane.has-thread .chat-side { display: none; }
.chat-pane:not(.has-thread) .chat-thread { display: none; }
.chat-side-head { display: none; }   /* the phone has a screen title instead */
.chat-blank { display: none; }       /* only a two-pane layout has a blank side */

/* ── One conversation ── */
.chat-thread { display: flex; flex-direction: column; min-width: 0; }

.chat-head {
  display: flex; align-items: center; gap: 10px; flex: 0 0 auto;
  padding: 8px 0 12px; border-bottom: 1px solid var(--border);
}
.chat-head-body { flex: 1; min-width: 0; }
.chat-head-name {
  display: block; font-size: var(--fs-body); font-weight: var(--fw-medium); color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chat-head-role { display: block; font-size: var(--fs-small); color: var(--text-dim); margin-top: 1px; }

/* The worker's side of the same slot. NOT .chat-head-role: the client's side
   puts the worker's PROFESSION there, and reusing it here put the service
   booked under the client's name — so "Backend developer" read as the client's
   job title. The icon and the booking code are what make this read as the job
   the conversation is about. */
.chat-head-job {
  display: flex; align-items: center; gap: 5px; margin-top: 2px;
  font-size: var(--fs-small); color: var(--text-faint);
  overflow: hidden;
}
.chat-head-job svg { width: 12px; height: 12px; flex: 0 0 auto; }
.chat-head-job > span {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.chat-body {
  /* min-height:0 is what lets a flex child actually scroll rather than grow. */
  flex: 1; min-height: 0;
  overflow-y: auto;
  /* A flick at the top of the thread must not start dragging the page behind
     it — on a phone that reads as the chat falling apart. */
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  padding: 14px 2px;
  display: flex; flex-direction: column;
}

.chat-day { text-align: center; margin: 14px 0 10px; }
.chat-day span {
  display: inline-block; padding: 4px 10px; border-radius: 10px;
  background: var(--surface-strong); color: var(--text-faint);
  font-size: var(--fs-caps); font-weight: var(--fw-medium);
}

.msg {
  max-width: 82%; align-self: flex-start; margin-top: 8px;
  padding: 9px 13px; border-radius: 18px 18px 18px 6px;
  background: var(--surface-strong); border: 1px solid var(--border);
}
.msg-mine {
  align-self: flex-end;
  background: var(--gold); border-color: transparent;
  border-radius: 18px 18px 6px 18px;
}
/* A run from one side sits tighter, so it reads as one turn. */
.msg-run { margin-top: 2px; }
.msg-body {
  font-size: var(--fs-body); line-height: 1.5; color: var(--text);
  /* A pasted URL wraps instead of widening the bubble. */
  white-space: pre-wrap; overflow-wrap: anywhere;
}
.msg-mine .msg-body { color: var(--on-gold); }
.msg-meta { margin-top: 4px; font-size: var(--fs-nav); color: var(--text-faint); text-align: right; }
.msg-mine .msg-meta { color: var(--on-gold); opacity: 0.7; }

/* A message that has left the composer but not yet reached the server. It is
   on screen immediately because the alternative — a message that vanishes for
   400ms — makes people send it twice. */
.msg-pending { opacity: 0.62; }
.msg-failed { background: var(--danger-bg); border-color: var(--danger); }
.msg-failed .msg-body { color: var(--text); }
.msg-retry {
  margin-top: 6px; background: none; border: none; padding: 0; cursor: pointer;
  font-family: inherit; font-size: var(--fs-caps); font-weight: var(--fw-medium);
  color: var(--danger); text-decoration: underline;
}

.chat-composer {
  display: flex; align-items: flex-end; gap: 8px; flex: 0 0 auto;
  padding-top: 12px; border-top: 1px solid var(--border);
}
.chat-input {
  flex: 1; min-height: 44px; max-height: 140px; resize: none;
  padding: 12px 16px; border-radius: 22px;
  background: var(--surface-strong); border: 1px solid var(--border-strong);
  color: var(--text); font-family: inherit;
  /* 16px or iOS zooms the page the moment this takes focus. */
  font-size: var(--fs-input); line-height: 1.4;
}
.chat-input:focus { outline: none; border-color: var(--gold); }
.chat-send {
  flex: 0 0 auto; width: 44px; height: 44px; border-radius: 50%;
  background: var(--gold); color: var(--on-gold); border: none; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
/* Without this the button is an empty gold circle: an inline <svg> with a
   viewBox and no dimensions has no intrinsic size, and as a flex item it
   collapses to nothing. Every other icon on the site is sized by its own
   component rule, and this one was simply missing. */
.chat-send svg { width: 20px; height: 20px; }
.chat-send:disabled { opacity: 0.45; cursor: default; }

.chat-closed-bar {
  flex: 0 0 auto;
  margin-top: 12px; padding: 14px; border-top: 1px solid var(--border);
  text-align: center; font-size: var(--fs-small); color: var(--text-faint);
}

/* ── Full-bleed on a phone ───────────────────────────────────────────────────
   A conversation takes the whole screen. The site's top bar and tab bar are
   navigation for a page you are reading; a chat is a place you are *in*, and
   every app that does this well gets out of the way while you are in it. The
   back button in the chat header is the way out.

   Fixed to the VISUAL viewport rather than the layout one. That is the whole
   trick for the keyboard: on iOS the layout viewport does not shrink when the
   keyboard opens, so anything positioned against it ends up underneath the
   keys. --vv-h and --vv-top are written by JS from window.visualViewport and
   fall back to dvh where the API is missing. */
/* Phones only. On a desktop the rail is the navigation and there is nothing
   to get out of the way of — the conversation sits beside the list instead. */
@media (max-width: 899px) {
  /* THE SHELL IS NOT THIS RULE'S JOB ANY MORE. `chat-open` used to strip the
     top bar and the tabs itself, which is how the product ended up with three
     different mechanisms for the same idea. EbuPortal.chrome() marks a
     conversation `off` like every other task, and what is left here is the
     part that is genuinely about a conversation: pinning to the visual
     viewport so the keyboard cannot cover the composer. */
  body.chat-open { overflow: hidden; }
  body.chat-open .wrap { padding: 0; max-width: none; }

  body.chat-open .chat-thread {
    position: fixed; z-index: 60;
    top: var(--vv-top, 0px); left: 0; right: 0;
    height: var(--vv-h, 100dvh);
    background: var(--bg);
    padding: 0 14px;
  }
  /* The top bar is gone, so the header takes over clearing the notch. */
  body.chat-open .chat-head { padding-top: calc(8px + env(safe-area-inset-top)); }
  /* And the tab bar is gone, so the composer clears the home indicator —
     except while the keyboard is up, where the keys already fill that space. */
  body.chat-open .chat-composer { padding-bottom: max(env(safe-area-inset-bottom), 10px); }
  body.chat-open.kb-open .chat-composer { padding-bottom: 8px; }
}


/* ── EBUCHAT on a desktop ────────────────────────────────────────────────────
   Placed after the phone rules on purpose: these are overrides, and letting
   source order do that work is what keeps !important out of the file. */
@media (min-width: 900px) {
  /* ── EBUCHAT: the list and the conversation, side by side ──
     One pane is right on a phone and wasteful here: the inbox is the thing you
     move between conversations with, and hiding it to read one of them means
     a round trip through a back button for every switch. */

  /* The portal is a 620px reading column, which is right for a settings screen
     and absurd for two panes — messages wrapped after three words. EBUCHAT is
     the one view that wants the desk. */
  body.chat-view .wrap { max-width: 1180px; }

  .chat-pane {
    display: flex; align-items: stretch;
    height: calc(100dvh - 64px - 18px - 48px);
    min-height: 420px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    overflow: hidden;
  }
  /* The list stays on screen with a conversation open, unlike the phone. */
  .chat-pane.has-thread .chat-side { display: flex; }
  .chat-grip { display: block; }

  /* RESIZABLE, and the width is a custom property so it survives the wholesale
     re-render both portals do on every poll — a width held in a JS variable
     would have to be re-applied by whoever remembered to, after every render.
     portal-chrome.js writes it; the stylesheet reads it. */
  .chat-side {
    flex: 0 0 var(--chat-side-w, 320px); min-width: 0;
    display: flex; flex-direction: column;
    background: var(--bg-elevated);
  }

  /* THE GRIP. Six pixels of hit area around a one-pixel line, which is what
     makes it grabbable without making it look like a component. */
  .chat-grip {
    flex: 0 0 6px; align-self: stretch;
    position: relative; cursor: col-resize;
    background: none; border: 0; padding: 0;
    touch-action: none;   /* or a drag scrolls the page on a touch laptop */
  }
  .chat-grip::before {
    content: ''; position: absolute; inset: 0 auto 0 2px;
    width: 1px; background: var(--border);
    transition: background var(--motion-tap) ease, width var(--motion-tap) ease;
  }
  .chat-grip:hover::before,
  .chat-grip:focus-visible::before { width: 2px; background: var(--gold); }
  .chat-grip:focus-visible { outline: none; }
  /* While dragging, the cursor stays a resize cursor everywhere and nothing
     selects — without this a fast drag highlights the whole conversation. */
  body.is-resizing { cursor: col-resize; user-select: none; }
  body.is-resizing .chat-grip::before { width: 2px; background: var(--gold); }
  @media (prefers-reduced-motion: reduce) {
    .chat-grip::before { transition: none; }
  }
  .chat-side-head {
    display: block; flex: 0 0 auto;
    padding: 16px 16px 12px; border-bottom: 1px solid var(--border);
  }
  .chat-side-title { font-size: var(--fs-body); font-weight: var(--fw-heading); letter-spacing: -.2px; }
  .chat-side-sub { font-size: var(--fs-small); color: var(--text-dim); margin-top: 2px; }
  .chat-side .chat-list { flex: 1; min-height: 0; overflow-y: auto; padding: 6px 10px; }
  .chat-side .chat-row { padding: 11px 8px; border-radius: var(--radius-md); border-bottom: none; }
  .chat-side .chat-row.is-open { background: var(--gold-soft); }

  .chat-thread { flex: 1; min-width: 0; padding: 0 18px; }
  /* The list is right there, so there is nothing to go back to. */
  .chat-thread .chat-back { display: none; }
  .chat-head { padding-top: 14px; }
  .chat-composer { padding-bottom: 16px; }

  /* Nothing open yet. An empty right-hand pane with no explanation reads as a
     page that failed to load. */
  .chat-blank {
    display: flex; flex: 1; flex-direction: column;
    align-items: center; justify-content: center; text-align: center;
    padding: 40px 24px; color: var(--text-dim);
  }
  .chat-blank .empty-circle { margin-bottom: 14px; }
  .chat-blank-title { font-size: var(--fs-body); font-weight: var(--fw-heading); color: var(--text); }
  .chat-blank-text { font-size: var(--fs-sub); margin-top: 6px; max-width: 34ch; }

  /* Wide enough to read, capped so a long message does not run past a
     comfortable measure across a large screen. */
  .msg { max-width: min(68%, 58ch); }
}

/* A link into EBUCHAT from a booking. */
.row-chat { width: 100%; background: none; border: none; font-family: inherit;
  color: inherit; cursor: pointer; text-align: left; }

.hint-error { color: var(--danger); font-weight: 600; }

/* ── Am I free today? ──────────────────────────────────────────────────────
   The worker's own availability, at the top of their home screen. Big enough
   to be a decision rather than a setting: it is the first thing they change
   when they finish for the day, and the last thing anyone should have to hunt
   for in a settings list. */
.duty {
  display: flex; align-items: center; gap: 12px; width: 100%;
  margin-bottom: 16px; padding: 14px 16px;
  border-radius: var(--radius-lg);
  background: var(--surface); border: 1px solid var(--border);
  font-family: inherit; color: inherit; text-align: left; cursor: pointer;
  transition: border-color var(--motion-control) ease, background-color var(--motion-control) ease;
}
.duty.is-on { border-color: var(--gold-line); background: var(--gold-soft); }
.duty:disabled { opacity: .6; cursor: default; }

.duty-dot {
  flex: 0 0 auto; width: 10px; height: 10px; border-radius: 50%;
  background: var(--text-faint);
}
.duty.is-on .duty-dot {
  background: var(--success);
  /* A quiet halo rather than an animation — this sits on screen all day. */
  box-shadow: 0 0 0 4px var(--success-bg);
}

.duty-body { flex: 1; min-width: 0; }
.duty-label { display: block; font-size: var(--fs-label); font-weight: var(--fw-medium); color: var(--text); }
.duty-hint {
  display: block; margin-top: 2px; font-size: var(--fs-small); color: var(--text-dim);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.duty-switch {
  flex: 0 0 auto; position: relative;
  width: 46px; height: 28px; border-radius: var(--radius-pill);
  background: var(--surface-strong); border: 1px solid var(--border-strong);
  transition: background-color var(--motion-control) ease, border-color var(--motion-control) ease;
}
.duty.is-on .duty-switch { background: var(--gold); border-color: transparent; }
/* The knob is LIGHT in both themes, not --bg-elevated. In dark, --bg-elevated
   is #151517 — a near-black knob on a near-black track, so the switch had no
   visible off state at all: the "I'm at this address now" toggle on the
   booking form and the worker's duty switch both read as an empty dark pill.
   .switch-thumb next door already hardcodes #fff for the same reason. On the
   gold ON track a light knob still reads, so one colour serves both states. */
.duty-knob {
  position: absolute; top: 3px; left: 3px;
  width: 20px; height: 20px; border-radius: 50%;
  background: #F1F1F4; box-shadow: 0 1px 3px rgba(0, 0, 0, .35);
  transition: transform var(--motion-control) ease;
}
.duty.is-on .duty-knob { transform: translateX(18px); }

/* "I'm at this address now", on the booking form. Same switch, different job:
   it qualifies the address field above it rather than standing alone, so it
   loses the card and gains a hairline. */
.at-here {
  margin: 14px 0 0; padding: 14px 0 0;
  background: none; border: none;
  border-top: 1px solid var(--border); border-radius: var(--radius-md);
}
.at-here.is-on { background: none; border-color: var(--border); }
/* The base hint is one line with an ellipsis, because a worker's off-duty
   line is short and lives in a fixed row. This copy is a sentence and must
   wrap — truncated to "Optional. Turn on for a live dist…" it says nothing. */
.at-here .duty-hint { white-space: normal; overflow: visible; line-height: 1.45; }
/* Waiting on the browser's permission prompt. A pulse rather than a spinner:
   the wait is on a dialog the browser is drawing, not on us, so the right
   signal is "this is live and listening", not "we are computing something". */
.at-here.is-busy { opacity: .72; cursor: progress; }
.at-here.is-busy .duty-switch { animation: at-here-wait 1.1s ease-in-out infinite; }
@keyframes at-here-wait {
  0%, 100% { opacity: 1; }
  50%      { opacity: .45; }
}
@media (prefers-reduced-motion: reduce) {
  .at-here.is-busy .duty-switch { animation: none; opacity: .6; }
}

/* ── The notification feed ─────────────────────────────────────────────────
   A bell in the top bar rather than a tab: it is a place you check, not a
   place you work, and both tab bars are already full. */
.bell { position: relative; }
.bell-badge {
  position: absolute; top: -2px; right: -2px;
  min-width: 17px; height: 17px; padding: 0 4px;
  border-radius: var(--radius-pill);
  background: var(--danger); color: #fff;
  font-size: var(--fs-badge); font-weight: var(--fw-figure); line-height: 17px; text-align: center;
  /* A ring in the bar's own colour, so the badge reads as sitting on the bell
     rather than smeared into whatever is behind it. */
  box-shadow: 0 0 0 2px var(--bg);
}

.notif-list { display: flex; flex-direction: column; }
.notif {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 13px 4px; border-bottom: 1px solid var(--border);
}
.notif:last-child { border-bottom: none; }

.notif-icon {
  flex: 0 0 auto; width: 34px; height: 34px; border-radius: 50%;
  background: var(--surface-strong); color: var(--text-dim);
  display: inline-flex; align-items: center; justify-content: center;
}
.notif-icon svg { width: 16px; height: 16px; }
.notif.is-new .notif-icon { background: var(--gold-soft); color: var(--gold-deep); }

.notif-main {
  flex: 1; min-width: 0; padding: 0; min-height: var(--tap);
  background: none; border: none; font-family: inherit; color: inherit;
  text-align: left; cursor: pointer;
}
.notif-title { display: block; font-size: var(--fs-body); font-weight: var(--fw-label); color: var(--text); }
.notif.is-new .notif-title { font-weight: var(--fw-figure); }
.notif-body {
  display: block; margin-top: 1px; font-size: var(--fs-meta); color: var(--text-dim);
  /* Wrapped, not truncated. A notification is one or two lines and the whole
     point of the feed is reading the one you missed. */
  overflow-wrap: anywhere;
}
.notif-when { display: block; margin-top: 3px; font-size: var(--fs-caps); color: var(--text-faint); }

.notif-x {
  flex: 0 0 auto; width: 40px; height: 40px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: none; color: var(--text-faint); cursor: pointer;
  border-radius: var(--radius-pill);
}
.notif-x:hover { color: var(--text); background: var(--surface-strong); }
.notif-x svg { width: 14px; height: 14px; }

/* ═══════════════════════════════════════════════════════════════════════════
   THE LIVE CARD
   ---------------------------------------------------------------------------
   One dark surface for the one thing happening right now: a verified
   professional on their way to a client's door. It is the only element on
   these screens allowed to be loud, and everything around it is deliberately
   quiet so it reads as the live thing rather than another card in a stack.

   Why it exists. The account section was every-card-the-same: white rounded
   rectangles on light grey, so a job in progress carried the same weight as a
   link to the safety page. Worse, the ETA was already in the data and being
   computed — "About 18 min away" — but only rendered two taps deep on the
   detail screen. The number a client actually opens the page for was the one
   thing the page did not show them.

   Used on Home (the hero) and at the top of a booking's detail.
   ═══════════════════════════════════════════════════════════════════════════ */

.live {
  position: relative;
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  color: var(--on-ink);
  background: var(--ink);
  background-image:
    radial-gradient(120% 80% at 100% 0%, rgba(240, 208, 96, 0.13) 0%, rgba(240, 208, 96, 0) 60%);
  border: 1px solid var(--ink-line);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-ink);
  padding: var(--sp-card) var(--sp-card) calc(var(--sp-card) + 2px);
  margin-bottom: var(--sp-gap);
  cursor: pointer;
  overflow: hidden;
  -webkit-tap-highlight-color: transparent;
}/* Status line: a live dot, the stage, and the tracking code sitting quietly
   at the far end where it is findable but not shouting. */
.live-top {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}@keyframes live-pulse {
  0%   { box-shadow: 0 0 0 0 rgba(240, 208, 96, 0.5); }
  70%  { box-shadow: 0 0 0 7px rgba(240, 208, 96, 0); }
  100% { box-shadow: 0 0 0 0 rgba(240, 208, 96, 0); }
}/* The ETA is the hero. When there is no estimate yet the stage headline takes
   its place at the same size, so the card never collapses into a smaller,
   sadder version of itself while it waits for a number. */
.live-eta {
  font-size: var(--fs-live);
  font-weight: var(--fw-heading);
  letter-spacing: -.6px;
  line-height: 1.06;
  color: var(--on-ink);
  text-wrap: balance;
}/* The professional, with a face. `photoUrl` was already on the API and the
   old card showed initials in a grey circle; on Bolt you see your driver. */
.live-pro {
  display: flex;
  align-items: center;
  gap: 11px;
  margin-top: 15px;
  padding-top: 14px;
  border-top: 1px solid var(--ink-line);
}
.live-pro .avatar {
  flex: 0 0 auto;
  width: 40px; height: 40px;
  border: 1px solid rgba(240, 208, 96, 0.34);
  background: var(--ink-2);
  color: var(--gold-on-ink);
}/* The journey, as four ticks rather than a seven-item bulleted list. The list
   belongs on the detail screen where there is room to explain each step; here
   it only has to answer "how far along is this". */
.live-rail { display: flex; gap: 4px; margin-top: 15px; }.live-tick.is-done { background: var(--gold-on-ink); }
.live-tick.is-now {
  background: var(--gold-on-ink);
  animation: live-now 2s ease-in-out infinite;
}
@keyframes live-now { 0%, 100% { opacity: 1; } 50% { opacity: .42; } }
.live-rail-label {
  display: flex; justify-content: space-between;
  font-size: var(--fs-tiny);
  color: var(--on-ink-faint);
  margin-top: 7px;
}@media (prefers-reduced-motion: reduce) {
  .live-dot, .live-tick.is-now { animation: none; }
}

/* ── Quick actions ─────────────────────────────────────────────────────────
   Replaces a row of stat tiles that read "2 Bookings / 2 Saved addresses" —
   numbers nobody needs at a glance and, worse, nothing you could press. Three
   things a client actually wants to do, at a size that says so. */
.qa { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: var(--sp-gap); }/* ── Booking cards in the list ─────────────────────────────────────────────
   The old list card was a service name, a code, a date and a worker's name on
   a white rectangle — no photo, no ETA, no amount, and 60% of the screen left
   empty underneath. */
.bcard {
  display: block;               /* .bcard-main is the row; the card is a stack */
  position: relative; overflow: hidden;
  width: 100%;
  padding: 12px var(--sp-card);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  color: var(--text);
  font: inherit; text-align: left;
  cursor: pointer;
  margin-bottom: 8px;
}.bcard .avatar { flex: 0 0 auto; width: 40px; height: 40px; }
/* These are <span>s inside a <button>, because a <button> may not contain a
   <div>. Spans are inline by default, so without this the service name and the
   professional's name ran together on one line — "Fan repairsChidi Obi". */
.bcard-body { flex: 1; min-width: 0; display: block; }/* The live half of a booking card: an eyebrow above the row and a progress
   rail under it. Both are drawn only for an active job — this is what lets the
   Bookings list show every running job honestly without repeating Home's hero
   card, which is what made the two screens read as the same screen twice. */
.bcard-eyebrow {
  display: flex; align-items: center; gap: 6px;
  margin-bottom: 9px;
}/* A stage stripe down the left edge, so a list of bookings is scannable by
   colour before a word of it is read. */
.bcard::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
  background: var(--stage-c, transparent);
}.ident .avatar-wrap { position: relative; display: inline-block; }
.ident .avatar {
  width: 84px; height: 84px;
  font-size: var(--fs-figure);
  border: 2px solid var(--gold-line);
  box-shadow: 0 0 0 5px var(--gold-soft);
}/* ── Row refinements ───────────────────────────────────────────────────────
   Two details that separate a settings list that feels considered from one
   that feels generated, applied to BOTH portals because both had them wrong:

   1. The divider is inset to where the label starts, instead of running
      full-bleed under the icon. A rule that cuts through a column of icons
      makes the list read as a stack of separate boxes; a rule that starts at
      the text reads as one list with items in it.
   2. The glyph takes the text colour rather than the dim one. At 17px inside
      a grey disc, --text-dim rendered every icon as a smudge, and a column of
      smudges is most of why this screen read as dull.
   Scoped to .group-body so a standalone .row (a page header, a chat row) is
   untouched. */
.group-body .row { border-bottom: none; position: relative; }
.group-body .row + .row::before {
  content: "";
  position: absolute; top: 0; right: 0;
  left: calc(var(--sp-row-x) + 34px + 11px);
  height: 1px;
  background: var(--border);
}
.row-icon { color: var(--text); background: var(--surface-strong); }
.row-icon svg { opacity: .88; }

/* A stronger ring on the account's own avatar. The first cut used
   --gold-line over --gold-soft, which at 84px reads as a pale halo rather
   than a frame — the client's own face should be the one warm thing on a
   screen of settings rows. */
.ident .avatar {
  border-color: var(--gold);
  box-shadow: 0 0 0 5px var(--gold-soft), var(--shadow-card);
  background: var(--gold-soft);
}

/* The current value of a settings row, on the right before the chevron. */
.row-value {
  flex: 0 0 auto;
  font-size: var(--fs-small);
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/* ═══════════════════════════════════════════════════════════════════════════
   THE SWISS LAYER — BOTH SIGNED-IN PORTALS
   ---------------------------------------------------------------------------
   Everything below is scoped to `[data-portal]` and reaches NOTHING without
   it. The attribute is an OPT-IN, not a name: a page that sets it — to
   "account" or to "worker" — is on this design, and the value is only there
   so a rule can address one portal on the rare occasion it has to. Everything
   above this line is the shared chrome both portals sit on.

   IT WAS SCOPED TO `[data-portal="account"]` UNTIL THE WORKER CAME ACROSS.
   Which is worth recording, because the separation was deliberate at the time
   and its removal is the point: the customer account was redesigned first and
   the worker portal was left on the old light/dark system with its theme
   toggle, so a worker and the client they were serving met two different
   products from one company — often on the same phone, in the same doorway.
   The mobile app had exactly this split and closed it the same way. Widening
   the scope, rather than copying the layer, is what stops them drifting apart
   again: there is one design here, and both portals are inside it.

   THE WORKER PORTAL IS DARK-ONLY NOW, and its theme toggle is gone. The
   argument for keeping it was that a worker is outdoors at night — which is
   an argument FOR this ground, not for a switch. Half this vocabulary does not
   survive a light ground (a faint gold wash between two gold rules is a smudge
   on white), and a toggle that quietly downgrades the design is worse than not
   having one.

   This is a PORT, not an interpretation. The mobile app runs on
   src/components/swiss.tsx, and the whole point of this file is that the two
   are the same design — someone who books on the phone and pays on a laptop,
   or a worker who accepts a job on the phone and checks their payouts on a
   laptop, should not feel they have moved between two products. Where a number
   appears here it was read off the app, not invented: 18px screen padding,
   a 40px display, 42px icon tiles, a 46×26 switch.

   It replaces the "account gloss" — gradient edges, gradient numerals, glossy
   buttons and two ambient washes — which was a faithful port of the app's
   OLD language (gloss.tsx, since deleted). The app moved; this follows.

   FOUR RULES CARRY IT, and breaking any one stops it being this design.

     1. A SOFT SQUARE, ON A SIX-STEP SCALE. --radius-* below hold the only
        curves this layer uses — xs 4, sm 10, md 14, lg 20, xl 24, pill — and
        they are the same six numbers as `radius` in the app's src/theme.ts.
        Nothing here writes a literal: a hand-written `border-radius: 7px` is
        the drift nobody catches in review and everybody feels on the screen,
        and scripts/check-swiss.js fails on one.

        THE CURVE RISES WITH THE BOX, which is why it is a scale and not one
        number. The token block below says which size is for what, and why
        this rule used to read NO CORNER RADIUS, ANYWHERE.

        WHAT STAYS SQUARE, deliberately: a full-bleed band. A row that touches
        both edges of the viewport (`.hp`, the owed rows), a 2px rule, a
        progress rail. It has no corner of its own, so the curve lands inside
        the device's edge and reads as a notch cut out of it. A sheet anchored
        to the bottom gets its TOP TWO CORNERS only; the dimmer behind it gets
        none.

        ONE SHAPE CARRIES MEANING, the same one the app makes: the client's
        own profile photo is a CIRCLE. Everything else here is a CONTAINER —
        a tag, a tile, a button — and a face is not one; a square portrait of
        yourself reads as an ID card rather than as you. The avatars in the
        BOOKING list stay a rounded square: those anchor a dense row, which is
        a layout job, not a personal one.

     2. NO SHADOWS, NO CARD FILLS. Separation is a 1px hairline, a 2px rule,
        or air. A card would put a box around something already grouped.

        Where something HAS to be the loudest thing on a screen — an arrival
        code, a live job, the current row of the status record — the
        construction is a faint gold ground between two gold rules. It marks
        without enclosing.

     3. WEIGHT DOES THE HIERARCHY, not size or colour. 800 names a thing, 600
        labels it, 400 is prose. This is why the account is set in Archivo and
        not in Plex: Plex's variable face stops at 700, so half the scale
        would silently round down to the weight below and the hierarchy would
        flatten. Archivo runs 100–900.

     4. THE ACCENT IS RARE. Gold marks what is LIVE and what is OWED, and
        nothing else. Not headings, not icons, not every tab.

   WHERE RULES GO AND WHERE AIR DOES. A dense list scanned fast — the bookings
   feed, the EBUCHAT inbox — earns a hairline between rows. A settings list
   read one item at a time does not: an eyebrow and a generous gap already say
   where a group starts, and stacked hairlines read as a table.
   ═══════════════════════════════════════════════════════════════════════════ */

:root[data-portal] {
  /* ── The ground ─────────────────────────────────────────────────────────
     Straight off `light` in the app's src/theme.ts. The name `light` there is
     a palette identifier, not a claim about brightness. */
  --bg: #0A0B0F;

  /* ── THE SURFACE SYSTEM ─────────────────────────────────────────────────
     Six levels, matching src/surfaces.ts in the app number for number, the
     same arrangement --radius-* and --motion-* already have.

     WHAT THIS REPLACED. `--bg`, `--bg-elevated` and `--surface` were all
     #0A0B0F — three names for one colour — exactly as `colors.bg` and
     `colors.bgElevated` were in the app. Every surface on both portals was
     the same tone, so nothing had any depth to read and the eye got no help
     telling content from control from chrome. It was reported as looking flat
     and dated, twice, with a reference design beside it the second time.

       ground    the page behind everything. No border.
       sunken    a well: an input, a track, a segmented groove. DARKER than
                 the ground, because a thing you type into should read as cut
                 into the page rather than sitting on it.
       base      ordinary content: a row, a block of facts.
       raised    a card that is its own object: a booking, a job, an entry.
       floating  chrome content slides UNDER: the tab bar, the top bar.
       overlay   a sheet or a drawer, over a scrim.

     Each is a little lighter than the one below, because on a near-black
     ground light falls from above. The steps are small — 3% to 9% white — on
     purpose: the goal is a surface you can tell from its neighbour, not a grey
     box.

     BLUR IS FOR TWO LEVELS ONLY. `backdrop-filter` forces a browser to
     composite everything behind the element on every frame, which on a
     scrolling list is exactly where it hurts. Chrome that content passes
     under earns it because the blur is what keeps text legible through it. A
     card does not. */
  --s-sunken: rgba(0, 0, 0, 0.28);
  --s-base: rgba(255, 255, 255, 0.032);
  --s-raised: rgba(255, 255, 255, 0.055);
  /* 0.88, NOT 0.72. At 0.72 with a 24px blur, a list scrolling under the tab
     bar was still legible THROUGH it: a screenshot of Home showed "Website
     build" and "Work in progress" ghosting behind the five labels. That is not
     translucency, it is a window, and it fails the one thing the level exists
     for. High enough to read the labels against, low enough that you can see
     something moving behind them. */
  --s-floating: rgba(20, 21, 27, 0.88);
  --s-overlay: rgba(18, 19, 25, 0.94);

  /* Edges. Quiet enough to define rather than enclose: a border heavier than
     the tonal step it sits on reads as a table. */
  --e-hair: rgba(255, 255, 255, 0.06);
  --e-line: rgba(255, 255, 255, 0.09);
  --e-chrome: rgba(255, 255, 255, 0.11);

  /* Elevation: wide, soft and nearly black. The shape of ambient light, not a
     hard drop shadow. */
  --lift-raised: 0 6px 14px rgba(0, 0, 0, 0.34);
  --lift-floating: 0 -2px 20px rgba(0, 0, 0, 0.42);
  --lift-overlay: 0 10px 30px rgba(0, 0, 0, 0.55);

  --blur-floating: 24px;
  --blur-overlay: 40px;

  /* KEPT, AND NOW DIFFERENT FROM THE GROUND. These three names are used in
     hundreds of places; pointing them at levels is what moves the whole
     portal onto the system without rewriting every rule. */
  --bg-elevated: var(--s-raised);
  --surface: var(--s-base);
  --surface-strong: var(--s-raised);
  --block: #22242B;

  --text: #EDEDF0;

  /* The neutral ramp, INVERTED from the artboard: on this ground 100 is the
     quietest and 500 the loudest — the reverse of a light design, where 900
     is the ink. Copying the artboard's values across would have produced a
     screen where the most important number on it was the hardest to read. */
  --n100: rgba(237, 237, 240, 0.28);
  --n200: rgba(237, 237, 240, 0.40);
  --n300: rgba(237, 237, 240, 0.52);
  --n400: rgba(237, 237, 240, 0.66);
  --n500: rgba(237, 237, 240, 0.80);

  --text-dim: var(--n300);
  --text-faint: var(--n200);

  /* Hairline and rule — the two weights the whole layout system is built
     from. There is no third. The artboard's divider is 40% of the text
     colour; the same ratio on a dark ground is far too strong, because
     light-on-dark hairlines read heavier than dark-on-light ones. */
  --hair: rgba(237, 237, 240, 0.13);
  --rule: rgba(237, 237, 240, 0.20);
  --border: var(--hair);
  --border-strong: var(--rule);

  --gold: #F2CB57;
  /* Emphasis. The artboard darkens to emphasise; on this ground we lighten. */
  --gold-hi: #FFE9A8;
  --gold-deep: #DCA624;
  --gold-soft: rgba(242, 203, 87, 0.13);
  --gold-halo: rgba(242, 203, 87, 0.22);
  --gold-line: rgba(242, 203, 87, 0.34);
  --on-gold: #17140C;

  --danger: #FF8A80;
  --danger-bg: rgba(255, 138, 128, 0.10);
  --success: #6FD79B;
  --success-bg: rgba(111, 215, 155, 0.12);
  --info: #7FB3F5;

  /* ── RULE 1, REWRITTEN: a soft square, not a sharp one ──────────────────
   *
   * This layer used to zero every radius. That was a real position — a Swiss
   * system is a grid of rectangles and a sharp corner is the honest expression
   * of one — but held for a whole app it reads as severe rather than as
   * disciplined, and severity is not what a client wants from the screen that
   * is about to take their money.
   *
   * So the rule is now a SCALE rather than a zero, and the scale is the design:
   *
   *   xs  4px   a mark, a tick, a checkbox. Anything under ~12px, where a
   *             larger radius would round it into a blob.
   *   sm 10px   controls and containers you touch: inputs, icon tiles, small
   *             buttons, avatars, skeletons.
   *   md 14px   the workhorse. Cards, rows, tiles, buttons, message bubbles,
   *             tabs. Most of the app is this.
   *   lg 20px   the few blocks that own a whole screen: a sheet, the ID card,
   *             the arrival code, the auth card.
   *   pill      genuinely pill-shaped things, and only those: a count badge,
   *             a switch track, a dot.
   *
   * THE SCALE RISES WITH THE BOX, which is the whole reason it is a scale and
   * not one number. A 20px radius on a 24px badge is a circle; a 6px radius on
   * a full-width sheet is a rectangle with the corners filed off. Matching the
   * curve to the size is what makes eight different components read as one
   * family.
   *
   * ONE EXCEPTION SURVIVES from the old rule, and it is the same one: the
   * client's own profile photo is a circle. A face is not a container.
   *
   * scripts/check-swiss.js enforces this scale — a radius that is not one of
   * these tokens fails, which is the same guard the zero had, pointed at a
   * different value. */
  --radius-xs: 4px;
  --radius-sm: 10px;
  --radius-md: 14px;
  --radius-lg: 20px;
  --radius-xl: 24px;
  --radius-pill: 999px;

  /* RULE 2. Kept as tokens rather than deleted so the shared rules above
     that reference them resolve to nothing instead of to `undefined`. */
  --shadow-card: none;
  --shadow-pop: none;
  --shadow-ink: none;

  /* ── Type ───────────────────────────────────────────────────────────────
     Named for the JOB the text does, not for a size — the app's `type`
     object, line for line. */
  --fs-display: 40px;   /* a screen title; only one per screen */
  --fs-title:   30px;   /* a detail screen's title, which must survive a wrap */
  --fs-item:    16px;   /* names a thing in a list */
  --fs-h1:      19px;
  --fs-h2:      16px;
  --fs-h3:      15px;
  --fs-body:    13px;
  --fs-label:   14px;
  --fs-sub:     13px;
  --fs-meta:    12px;
  --fs-small:   11.5px;
  --fs-caps:    11px;   /* an eyebrow over a section */
  --fs-caps-sm: 10px;   /* an eyebrow over an item */
  --fs-tiny:    10px;
  --fs-nav:     10px;
  --fs-badge:   11px;

  --fs-live: 44px;      /* the ETA figure on the live card */
  --fs-figure: 22px;    /* a total */

  /* 16px is a HARD FLOOR for anything typed into. Below it iOS zooms the
     whole page on focus, and on a form that is the difference between
     filling it in and giving up. scripts/check-type-floors.js asserts it. */
  --fs-input: 16px;

  --fw-display: 800;
  --fw-heading: 800;
  --fw-label:   600;
  --fw-medium:  600;
  --fw-caps:    600;
  --fw-figure:  800;

  /* ── Space ──────────────────────────────────────────────────────────────
     18px is the app's screen gutter and everything hangs off it. */
  --sp-wrap-x: 18px;
  --sp-wrap-y: 0px;      /* the masthead owns its own top padding */
  --sp-card:   18px;
  --sp-gap:    0px;      /* air and rules separate, not margins between cards */
  /* A SETTINGS ROW IS ~62px, NOT ~74px. 16px of padding around a 42px icon
     block gave a 74px row, and a column of those reads as a stack of cards
     rather than as a list you run your eye down. Every settings screen people
     already use lands between 56 and 64. */
  --sp-group:  22px;
  --sp-row-y:  12px;
  --sp-row-x:  18px;

  --tap: 44px;
  --nav-h: 58px;
  --topbar-h: 56px;
  --wrap: 620px;

  /* ONE TYPEFACE. A Swiss system is a single grotesque at three weights, and
     Archivo is a neo-grotesque in exactly that lineage. Pairing a display
     face with a text face is the move the gloss made, and it is what made
     every screen read as two designs stacked. */
  font-family: 'Archivo', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

[data-portal] body {
  background: var(--bg);
  color: var(--text);
  font-family: 'Archivo', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  /* iOS paints a grey flash over anything tapped. On a design with no
     rounded corners it lands as a square grey block and reads as a bug. */
  -webkit-tap-highlight-color: transparent;
}

/* No ambient washes. The gloss had two large radial gradients fixed behind
   the page so "the ground is never a dead grey" — but a flat ground is the
   point here, and separation is meant to come from rules and air. The one
   gradient that survives is the account masthead's, and it earns its place
   by doing what a card would otherwise do (see .band). */
[data-portal] body::before,
[data-portal] body::after { content: none; display: none; }

/* NO STACKING CONTEXT ON .shell. It used to carry `position: relative;
   z-index: 1` to lift the content above the two ambient washes behind the
   page. Those washes are gone — and the rule outlived them with a bug in it:
   z-index on a positioned element opens a stacking context, so the top bar
   INSIDE the shell could never rise above the rail beside it, whatever
   z-index it was given. On desktop the rail (sticky, opaque) painted straight
   over the EBUTECH wordmark, and the bar simply had no brand on it.

   So the shell is left alone, and the two pieces of chrome keep the shared
   layer's own order: the bar above the rail, which is what the rail's 84px of
   top padding is there to clear. */

/* Focus has to be VISIBLE and it has to be square. The browser default is a
   rounded ring on some platforms, which would be the only radius on the
   screen. `:focus-visible`, not `:focus`, so it appears for the keyboard and
   not for every tap. */
[data-portal] :focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* ── The two weights ───────────────────────────────────────────────────────
   A hairline between rows of a dense list; a 2px rule between sections.
   Rendered as elements rather than borders because a section rule has to be
   able to sit between two blocks that own no border of their own — the same
   reason the app has a <Rule> component. */
[data-portal] .rule { height: 1px; background: var(--hair); border: 0; margin: 0; }
[data-portal] .rule.is-strong { height: 2px; background: var(--rule); }

/* ── An eyebrow ────────────────────────────────────────────────────────────
   Tracked-out uppercase above the thing it names. `is-accent` for what a row
   IS; plain for a section heading. */
[data-portal] .eyebrow,
[data-portal] .group-title,
[data-portal] .grp {
  font-size: var(--fs-caps);
  font-weight: var(--fw-caps);
  letter-spacing: .11em;
  text-transform: uppercase;
  color: var(--n300);
}
[data-portal] .eyebrow.is-accent { color: var(--gold); }
[data-portal] .eyebrow.is-sm { font-size: var(--fs-caps-sm); letter-spacing: .12em; }

/* ── Type roles ────────────────────────────────────────────────────────────
   Weight carries the hierarchy (rule 3), so these are named for the job the
   text does rather than for a size. */
[data-portal] .mast-title {
  font-size: var(--fs-display);
  font-weight: 800;
  line-height: 1;
  letter-spacing: -.02em;
  color: var(--text);
  margin: 0;
}
[data-portal] .mast-meta {
  font-size: var(--fs-sub); font-weight: 400; color: var(--n300);
  margin: 8px 0 0;
}
[data-portal] .h1 { font-size: var(--fs-h1); font-weight: 800; letter-spacing: -.015em; }
[data-portal] .small { font-size: var(--fs-meta); font-weight: 400; color: var(--n300); }
[data-portal] .hint  { font-size: var(--fs-small); font-weight: 400; color: var(--n200); line-height: 1.45; }
[data-portal] .dim   { color: var(--n300); }
[data-portal] .faint { color: var(--n200); }

/* Figures line up in a column or they are not figures. */
[data-portal] .amt,
[data-portal] .stat-v,
[data-portal] .row-value,
[data-portal] .tl-time,

/* ── The radius on the containers that never declared one ─────────────────
   The pass that converted `border-radius: 0` to the scale could only see rules
   that SAID zero. These ten paint a background or draw a full border and simply
   never mentioned a radius, so they stayed sharp among rounded neighbours —
   found by measuring every boxed element on fourteen screens rather than by
   reading the stylesheet, because that is the only way to find the ones nobody
   wrote a rule about. Token follows the box, as everywhere else. */
[data-portal] .lv-chat { border-radius: var(--radius-sm); }  /* 42x42 icon button on the live card */
[data-portal] .cat { border-radius: var(--radius-md); }  /* a browse tile */
[data-portal] .wal-act { border-radius: var(--radius-md); }  /* EBUPAY's two actions */
[data-portal] .chat-head { border-radius: var(--radius-md); }  /* the conversation header */
[data-portal] .pay-rail { border-radius: var(--radius-md); }  /* the EBUPAY block on a booking */
[data-portal] .stat { border-radius: var(--radius-md); }  /* an account stat tile */
[data-portal] .chat-composer { border-radius: var(--radius-md); }  /* the message composer */
[data-portal] .hp-ic { border-radius: var(--radius-sm); }  /* the prompt's 36px icon tile */
[data-portal] .strip-chip { border-radius: var(--radius-pill); }  /* the distance chip on the map strip */

/* ═══ SHELL ═══════════════════════════════════════════════════════════════ */

[data-portal] .wrap {
  max-width: var(--wrap);
  padding: 0 0 calc(var(--nav-h) + 28px + env(safe-area-inset-bottom));
}

/* The top bar. A hairline and a flat ground — no blur, no translucency: a
   frosted bar is a surface, and this system has one surface. */
[data-portal] .topbar {
  background: var(--bg);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  border-bottom: 1px solid var(--hair);
  padding-left: var(--sp-wrap-x);
  padding-right: var(--sp-wrap-x);
}
/* THE WORDMARK ALONE, hard against the gutter.
   The 24px mark beside it was a second EBUTECH logo on a bar that is already
   nothing but EBUTECH, and at 24px on a dark ground it read as a smudge
   rather than as a mark. The word is the brand here. `margin-left` pulls the
   link back over the bar's own padding so the E starts on the same vertical
   as every screen title below it — a wordmark indented further than the
   content it sits above looks like a mistake. */
[data-portal] .topbar-brand img { display: none; }
[data-portal] .topbar-brand { gap: 0; margin-left: -2px; }
[data-portal] .topbar-word {
  font-size: 13px; font-weight: 800;
  letter-spacing: .18em; color: var(--gold);
}

/* ── THE BAR GIVES WAY ON THREE SCREENS ───────────────────────────────────
   The top bar is one element shared by every screen, so a screen that needs
   less of it says so here rather than rendering its own — see
   `document.body.dataset.view` in account-page.js.

   EBUCHAT: the wordmark goes and the controls stay. EBUCHAT is the title
   directly below it and carries the brand in its own name, so the bar was
   saying "EBU" twice in a row, in two sizes, three lines apart.

   EBUPAY and the account: the whole bar goes. Both are screens somebody
   arrived at on purpose — one is a placeholder that has to be read in one
   glance, the other opens with a face and a name — and neither is helped by a
   brand, a booking CTA and a bell above it. The bell loses nothing on the
   account: Notifications is a row on that screen now.

   The ELEMENT survives so its `env(safe-area-inset-top)` padding still holds
   the content out of a notch. Only its contents, its height and its border
   go. Hidden with `display:none`, never `visibility` — a control you cannot
   see must not be tabbable or announced either. */
[data-portal] body[data-view="chats"] .topbar-brand,
[data-portal] body[data-view="chat"] .topbar-brand { display: none; }

/* THE COLLAPSED BAR — `chrome-min`, set by EbuPortal.chrome() rather than by a
   list of view names here. It used to be exactly this rule with three
   `body[data-view="…"]` selectors on it, which meant the shell's behaviour was
   defined in the stylesheet, one screen at a time, and nothing could check it.

   The ELEMENT survives so its `env(safe-area-inset-top)` padding still holds
   the content out of a notch. Only its contents, its height and its border go.

   `display: none` on the contents, NOT a fade to `visibility: hidden`. The
   fade was tried and reverted: `visibility` leaves the children in the box
   model, so absolutely positioned inside a bar with no height left they
   overhang the content beneath it — invisible, but taking up room and painting
   over the top of the screen. There is nothing to fade anyway; the whole view
   is replaced in the same frame. */
[data-portal] body.chrome-min .topbar {
  min-height: 0;
  padding-top: env(safe-area-inset-top);
  padding-bottom: 0;
  border-bottom: 0;
  background: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
/* Gone from the layout, not merely from sight: a control you cannot see must
   not be tabbable, announced, or holding a bar open that has no height left. */
[data-portal] body.chrome-min .topbar > * { display: none; }
[data-portal] .icon-btn {
  width: var(--tap); height: var(--tap);
  background: none; border: 1px solid var(--rule); color: var(--text);
}
[data-portal] .icon-btn:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); }
[data-portal] .icon-btn:active { transform: none; background: rgba(255, 255, 255, 0.09); }
/* The unread count on the top bar's bell. Square, gold, ground-coloured
   figure — an unread notification is something owed, which is one of the two
   things rule 4 lets the accent mark.

   THE RING IS A BORDER, NOT A SHADOW. The shared layer separates the badge
   from the bell behind it with `box-shadow: 0 0 0 2px var(--bg)`, which is a
   cut-out rather than a shadow in intent — but rule 2 does not have an
   "in intent" clause, and a design rule with one is not a rule. A 2px border
   in the ground colour does exactly the same job with a property this system
   allows, which is what `.rw-unread` and `.chat-dot` already do. */
[data-portal] .bell-badge {
  background: var(--gold); color: var(--on-gold); border-radius: var(--radius-pill);
  border: 2px solid var(--bg); box-shadow: none;
  font-weight: 800;
}

/* ── The tab bar ───────────────────────────────────────────────────────────
   FLUSH TO THE BOTTOM EDGE and separated by a rule, not floating on a
   rounded raft. A floating bar is a card, and rule 2 has no cards. */
[data-portal] .tabs {
  left: 0; right: 0; bottom: 0;
  /* THE FLAT SYSTEM TURNED THE BLUR OFF HERE and painted the bar in the
     ground colour with a 2px rule — which is a bar that looks stuck to the
     screen rather than floating above it, and is indistinguishable from the
     content it is supposed to sit over. */
  background: var(--s-floating);
  backdrop-filter: saturate(180%) blur(var(--blur-floating));
  -webkit-backdrop-filter: saturate(180%) blur(var(--blur-floating));
  border-top: 1px solid var(--e-chrome);
  box-shadow: var(--lift-floating);
}
[data-portal] .tab {
  color: var(--n200);
  font-size: var(--fs-nav); font-weight: 600;
  letter-spacing: .09em; text-transform: uppercase;
  gap: 5px;
}
[data-portal] .tab svg { width: 21px; height: 21px; }
/* Rule 4: the accent marks what is live and what is owed. The tab you are
   already on is neither — you can see the screen. Weight and full-strength
   ink say "here"; a gold icon would spend the accent on navigation, and then
   it means nothing when a job actually is live. */
[data-portal] .tab[aria-selected="true"] { color: var(--text); }
[data-portal] .tab[aria-selected="true"] svg { stroke-width: 2.1; }
/* The marker is a 2px gold rule ON the section rule, at the top of the tab —
   the same construction the current row of the status record uses. */
[data-portal] .tab[aria-selected="true"]::before {
  content: ""; position: absolute; top: -2px; left: 0; right: 0;
  height: 2px; background: var(--gold);
}
[data-portal] .tab-badge {
  background: var(--gold); color: var(--on-gold);
  font-size: var(--fs-badge); font-weight: 800;
  min-width: 17px; height: 17px; line-height: 17px;
}

/* ═══ CONTROLS ════════════════════════════════════════════════════════════ */

/* Solid gold, square, ground-coloured text. No ramp, no glow, no inset
   highlight — the button is the one gold block on most screens and it does
   not need help being found. */
[data-portal] .btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 9px;
  width: 100%; min-height: 50px; padding: 15px 18px;
  background: var(--gold); color: var(--on-gold);
  border: 0; box-shadow: none;
  font-family: inherit; font-size: 15px; font-weight: 800; letter-spacing: -.01em;
  cursor: pointer;
  transition: opacity var(--motion-tap) ease;
}
[data-portal] .btn:hover { filter: none; opacity: .88; }
[data-portal] .btn:active { opacity: .78; }
[data-portal] .btn[disabled],
/* ── WHAT A BUTTON SAYS AFTER YOU PRESS IT ────────────────────────────────
   Driven by `EbuPortal.btnState()`, which is the same two sequences the app's
   <Button> has: idle → working → done, and idle → working → failed.

   `is-busy` DIMS RATHER THAN SPINNING. A spinner inside a button that has just
   swapped its label to "Saving" says the same thing twice, and the label says
   it better — it names the verb.

   `is-failed` KEEPS ITS FULL WEIGHT and is deliberately still pressable: it is
   the way to try again, and a dimmed dead control is the exact dead end this
   exists to remove. It takes the danger colour rather than the gold, because
   the gold is reserved for what is live and what is owed. */
[data-portal] .btn.is-busy { opacity: .45; cursor: progress; }
[data-portal] .btn.is-done {
  background: var(--success); border-color: var(--success); color: #fff;
}
[data-portal] .btn.is-failed {
  background: none; border-color: var(--danger); color: var(--danger);
}
/* The label change is the whole point, so it should not read as a different
   button appearing in the same place. `control`: one control changing what it
   is. */
[data-portal] .btn {
  transition: background-color var(--motion-control) var(--ease),
              border-color var(--motion-control) var(--ease),
              color var(--motion-control) var(--ease),
              opacity var(--motion-control) var(--ease);
}
@media (prefers-reduced-motion: reduce) {
  [data-portal] .btn { transition: none; }
}
[data-portal] .btn svg { width: 17px; height: 17px; }

[data-portal] .btn-quiet {
  background: none; color: var(--text);
  border: 1px solid var(--rule);
}
[data-portal] .btn-quiet:hover { background: rgba(255, 255, 255, 0.05); opacity: 1; }
[data-portal] .btn-sm { min-height: 40px; padding: 10px 14px; font-size: 13px; }

/* ── The segmented control ─────────────────────────────────────────────────
   A square box split into halves. The chosen half is SOLID gold with
   ground-coloured text; the other is transparent. No indicator that slides,
   no pill, no tint — softening it is what would make it ordinary. */
[data-portal] .seg {
  display: inline-flex; align-self: flex-start;
  border: 1px solid var(--rule); background: none;
  padding: 0; gap: 0; overflow: hidden;
}
[data-portal] .seg-btn {
  display: inline-flex; align-items: center; gap: 8px;
  /* `--tap`, not 40. A segment is a target a thumb has to land on, and 40 was
     four short of the minimum on the one control people use most on the
     history screen. */
  min-height: var(--tap); padding: 10px 18px;
  background: none; border: 0; color: var(--text);
  font-family: inherit; font-size: 13px; font-weight: 800; letter-spacing: .02em;
  cursor: pointer;
}
[data-portal] .seg-btn.is-on { background: var(--gold); color: var(--on-gold); }
[data-portal] .seg-count { font-size: 11px; font-weight: 800; color: var(--n300); }
[data-portal] .seg-btn.is-on .seg-count { color: var(--on-gold); opacity: .7; }

/* ── A field ───────────────────────────────────────────────────────────────
   The label sits ABOVE the box, tracked out, never floating inside it. A
   placeholder doubling as a label disappears the moment somebody types,
   which is precisely when they most want to check what they are filling in. */
/* `.field` IS THE INPUT, not a wrapper around one. The app's <Field> puts the
   label and the box in a container; this page puts the class straight on the
   control and the label beside it. A first pass copied the app's shape —
   `.field input`, `.field select`, `input[type="text"]` — and not one of those
   selectors matched anything: the inputs here are `<input class="field">` with
   NO type attribute at all, so an attribute selector cannot see them either.
   They kept the shared layer's styling and only looked right because the
   radius token is already 0 in this scope. check-dead-css.js is what noticed. */
[data-portal] .field,
[data-portal] select,
[data-portal] textarea {
  width: 100%; min-height: 48px; padding: 12px 16px;
  /* A WELL, not a raised box. It was a LIGHTER fill than the page, which is
     the tone of something sitting on top — the opposite of what a field is.
     `sunken` puts it below the ground, so a form reads as a set of things cut
     into the page rather than a stack of pale rectangles. */
  background: var(--s-sunken);
  border: 1px solid var(--e-hair); border-radius: var(--radius-md);
  color: var(--text);
  font-family: inherit;
  /* The floor. See --fs-input. */
  font-size: var(--fs-input); font-weight: 400;
  -webkit-appearance: none; appearance: none;
}
[data-portal] .field:focus,
[data-portal] select:focus,
[data-portal] textarea:focus {
  outline: none; border-color: var(--gold); background: var(--s-base);
}
[data-portal] .field::placeholder,
[data-portal] textarea::placeholder { color: var(--n200); }
[data-portal] .field-label {
  display: block; margin-bottom: 7px;
  font-size: var(--fs-caps-sm); font-weight: 600;
  letter-spacing: .12em; text-transform: uppercase; color: var(--n300);
}
[data-portal] .consent input[type="checkbox"] {
  accent-color: var(--gold); border-radius: var(--radius-xs);
}
[data-portal] textarea { min-height: 110px; resize: vertical; line-height: 1.5; }
[data-portal] ::placeholder { color: var(--n200); }
[data-portal] .field-error { color: var(--danger); font-size: var(--fs-small); margin-top: 6px; }

/* ── The switch ────────────────────────────────────────────────────────────
   SQUARE — a square track with a square knob — and that is not a joke at the
   expense of a control people need to recognise. A hardware switch is the
   physical object this whole vocabulary comes from, and Braun's toggles are
   rectangles. The affordance is the TRAVEL, not the corner radius. */
[data-portal] .switch-track {
  width: 46px; height: 26px; padding: 2px; flex: 0 0 auto;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--rule); border-radius: var(--radius-pill);
  transition: background-color var(--motion-control) ease, border-color var(--motion-control) ease;
}
/* `.switch-thumb`, which is what the markup calls it. A first pass styled a
   `.switch-knob` that does not exist here, so the track squared and the knob
   stayed a white pill — one round corner in a design whose first rule is that
   there are none. */
[data-portal] .switch-thumb {
  width: 20px; height: 20px; border-radius: var(--radius-pill);
  background: var(--n200); box-shadow: none;
  transition: transform var(--motion-control) cubic-bezier(.33, 0, .2, 1), background-color var(--motion-control) ease;
}
[data-portal] .switch input:checked + .switch-track {
  background: var(--gold); border-color: var(--gold);
}
[data-portal] .switch input:checked + .switch-track .switch-thumb {
  background: var(--on-gold); transform: translateX(20px);
}

/* A <select> with `appearance: none` has no arrow, and a box with no arrow is
   an input people type into. Drawn as a background SVG rather than a wrapper
   element: it is one property, it cannot fall out of alignment, and there is
   no extra node to keep in sync with a control the browser owns. */
[data-portal] select {
  padding-right: 42px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9'%3E%3Cpath d='M1 1l6 6 6-6' fill='none' stroke='%23EDEDF0' stroke-opacity='.52' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 16px center;
}
/* A native option list is painted by the OS, so it takes the OS's colours
   unless it is told otherwise — on a dark page that is black text on white. */
[data-portal] select option { background: #14151B; color: var(--text); }

/* ── A tag ─────────────────────────────────────────────────────────────────
   A rectangle, never a pill. */
[data-portal] .tag,
[data-portal] .pill,
[data-portal] .chip,
[data-portal] .vchip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 10px; border-radius: var(--radius-pill);
  background: rgba(237, 237, 240, 0.07); border: 1px solid transparent;
  color: var(--n500);
  font-family: inherit; font-size: 11px; font-weight: 600; letter-spacing: .02em;
  text-transform: none;
}
[data-portal] .tag.is-accent,
[data-portal] .pill.is-accent,
[data-portal] .vchip-off {
  background: var(--gold-soft); border-color: transparent; color: var(--gold-hi);
}
[data-portal] .tag.is-outline {
  background: none; border-color: var(--gold); color: var(--gold);
}
[data-portal] .vchip { background: var(--success-bg); color: var(--success); }
[data-portal] .vchip svg,
[data-portal] .tag svg { width: 11px; height: 11px; }

/* The pulse. It marks ONE thing: this job is moving right now. Used anywhere
   else it becomes decoration and stops meaning anything. */
[data-portal] .dot,
[data-portal] .dot::after,
@keyframes sw-pulse {
  0%   { transform: scale(1);   opacity: .55; }
  100% { transform: scale(2.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  [data-portal] .dot::after { animation: none; opacity: 0; }
}

/* ═══ AVATARS ═════════════════════════════════════════════════════════════ */

/* SQUARE, everywhere except the client's own photo. A photo fills it edge to
   edge; with no photo it is a solid block with gold initials. */
[data-portal] .avatar,
[data-portal] .avatar-wrap,
[data-portal] .rw-ava,
[data-portal] .worker-photo {
  border-radius: var(--radius-sm);
  background: var(--block);
  border: 0; box-shadow: none;
  color: var(--gold);
  font-family: inherit; font-weight: 800; letter-spacing: .02em;
}
/* A booking with nobody assigned yet. A dashed box says "there is no person
   here yet"; an empty block would say "this person has no photo". */
[data-portal] .avatar.is-pending,
[data-portal] .rw-ava.is-pending {
  background: none; border: 1.5px dashed var(--n100);
}

/* THE ONE CIRCLE IN THE WHOLE DESIGN (rule 1's single exception): your own
   profile photo, on the account masthead. */
[data-portal] .band .avatar-wrap,
[data-portal] .ident .avatar-wrap {
  border-radius: 50%; padding: 2px; background: var(--gold);
}
[data-portal] .band .avatar,
[data-portal] .ident .avatar {
  border-radius: 50%; background: var(--block); overflow: hidden;
}
[data-portal] .band .avatar-edit-badge,
[data-portal] .ident .avatar-edit-badge {
  border-radius: 50%; background: var(--gold); color: var(--on-gold);
  border: 3px solid var(--bg);
  right: 0; bottom: 0; width: 26px; height: 26px;
}
[data-portal] .band .avatar-edit-badge svg,
[data-portal] .ident .avatar-edit-badge svg { width: 13px; height: 13px; }
[data-portal] .band .avatar-edit,
[data-portal] .ident .avatar-edit { display: block; width: 100%; height: 100%; }

/* ═══ LISTS ═══════════════════════════════════════════════════════════════ */

/* ── A settings group ──────────────────────────────────────────────────────
   An eyebrow, then the rows. NO RULES AT ALL — not around the group, not
   between its rows.

   That looks like a violation of rule 2 and is the opposite. The rules were
   doing nothing: an eyebrow and a generous gap already say where a group
   starts and stops, so every hairline was a second answer to a question
   nobody had asked, and six of them stacked read as a table. What separates
   rows here is AIR, which is why the row padding is larger than a line would
   have needed.

   The bookings list keeps its hairlines: those rows are dense and scanned
   fast. A settings list is read one item at a time. */
[data-portal] .group { margin-top: var(--sp-group); background: none; border: 0; box-shadow: none; }
[data-portal] .group-title { display: block; margin: 0 var(--sp-wrap-x) 12px; letter-spacing: .14em; }
[data-portal] .group-body { background: none; border: 0; box-shadow: none; padding: 0; }

[data-portal] .row,
[data-portal] .row-static {
  display: flex; align-items: center; gap: 15px;
  padding: var(--sp-row-y) var(--sp-row-x);
  background: none; border: 0;
  width: 100%; text-align: left;
  font-family: inherit; color: var(--text); cursor: pointer;
}
[data-portal] .row-static { cursor: default; }
[data-portal] .row:hover { background: rgba(255, 255, 255, 0.05); }
[data-portal] .row:active { background: rgba(255, 255, 255, 0.09); }
/* ── ROWS IN A GROUP ARE JOINED ────────────────────────────────────────────
   THE HAIRLINES ARE BACK, and this reverses a decision made here that turned
   out to be wrong on a phone.

   The argument was that the gap between rows is the separator, so a rule under
   every one "turns six settings into a table". That holds for a list of filled
   cards. It does not hold here: these rows have no fill, no border and no
   shadow, so with the rule gone the only marks on the screen were a column of
   42px grey icon squares floating in black at 74px intervals — six islands,
   not one list. It was reported, accurately, as "scattered".

   Full-bleed, not inset: an indented rule draws a second column edge that
   nothing else on the screen lines up with.

   The LAST row in a group has none, so the group still ends in air and the
   space before the next group's title is what separates them. That is the part
   the original note got right. */
[data-portal] .group-body .row + .row::before { content: none; }
[data-portal] .group-body .row + .row,
[data-portal] .group-body .row + .row-static,
[data-portal] .group-body .row-static + .row {
  border-top: 1px solid var(--hair);
}

/* A button that sizes to its label rather than to its row. .btn is full-width
   by default because that is what it is on nearly every screen it appears on.
   (`.topbar .btn` was here for the account bar's "Book a service", which is
   gone: Home offers it under the search field and the bar is the bell alone.
   `.btn-sm` and `.btn-auto` are still worn elsewhere.) */
[data-portal] .btn-sm,
[data-portal] .btn-auto { width: auto; }

/* A SQUARE 42px block. Squaring it is the one change that makes this row
   belong beside a booking row rather than beside a settings app. */
[data-portal] .row-icon {
  width: 38px; height: 38px; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(255, 255, 255, 0.05);
  border: 0; border-radius: var(--radius-sm); color: var(--n500);
}
[data-portal] .row-icon svg { width: 19px; height: 19px; }
/* Gold for the one or two rows in a section that are an OFFER rather than a
   setting — EBUPAY, invite a friend. Not for every row with an icon. */
[data-portal] .row-icon-gold { background: var(--gold-soft); color: var(--gold); }
[data-portal] .row-danger .row-icon { background: var(--danger-bg); color: var(--danger); }
[data-portal] .row-danger .row-label { color: var(--danger); }

[data-portal] .row-body { min-width: 0; flex: 1; }
[data-portal] .row-label { font-size: 15px; font-weight: 700; letter-spacing: -.01em; color: var(--text); }
[data-portal] .row-hint  { font-size: 12.5px; font-weight: 400; color: var(--n300); margin-top: 2px; }
[data-portal] .row-value { font-size: 13px; font-weight: 700; color: var(--n300); }
[data-portal] .row-chev { color: var(--n100); flex: 0 0 auto; }
[data-portal] .row-chev svg { width: 16px; height: 16px; }

/* ── A dense row ───────────────────────────────────────────────────────────
   The bookings feed and the EBUCHAT inbox: scanned fast, so a hairline
   between entries, full bleed — an indented rule draws a second, invisible
   column edge. */
/* ONE ROW SHAPE for live and finished, because they are the same object and
   a client scanning for "the cleaner" should not have to learn two layouts.
   What changes is what each side carries: the professional's face on the
   left, the service and then the most useful second line there is in the
   middle, a stage chip and the money on the right.

   ALIGNED TO THE TOP, not centred: the service name can wrap to two lines
   and a centred row would then float its avatar into the gap. */
/* A CARD, NOT A TABLE ROW. These were `background: none; border: 0;
   border-bottom: 1px solid var(--hair)` — four bookings on one flat tone with
   lines between them, which is what a screenshot of this list looked like and
   why it read as dated. Each booking is its OWN OBJECT, so it takes the
   `raised` level: its own tone, a 1px edge, a corner, a soft lift, and air
   between it and the next rather than a rule.

   The gutter moved from padding to margin. A row that pays its own gutter
   runs edge to edge and cannot have a corner; a card sits inside the gutter. */
[data-portal] .row-b,
[data-portal] .chat-row {
  position: relative;
  display: flex; align-items: flex-start; gap: 14px;
  padding: 15px var(--sp-card);
  margin: 0 var(--sp-wrap-x) 10px;
  width: calc(100% - (var(--sp-wrap-x) * 2));
  background: var(--s-raised);
  border: 1px solid var(--e-line);
  border-radius: var(--radius-lg);
  box-shadow: var(--lift-raised);
  text-align: left; font-family: inherit; color: var(--text);
  cursor: pointer;
}
[data-portal] .row-b:hover,
[data-portal] .chat-row:hover {
  background: var(--s-floating);
  border-color: var(--e-chrome);
}

/* The stripe is gone. It was a 3px bar of the stage colour down the left edge
   of every live row — a fifth thing saying "live" next to the ring, the chip,
   the ETA and the pulse, and the one of the five that a reader cannot name.
   Rule 4: the accent marks what is live ONCE. */
[data-portal] .rw-stripe { display: none; }

[data-portal] .rw-face { position: relative; flex: 0 0 auto; }
[data-portal] .rw-b { flex: 1; min-width: 0; }
[data-portal] .rw-t {
  display: flex; align-items: center; gap: 8px;
  font-size: var(--fs-item); font-weight: 800; letter-spacing: -.01em;
}
[data-portal] .rw-m {
  display: block; font-size: 12.5px; font-weight: 400; color: var(--n300); margin-top: 3px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
[data-portal] .rw-r {
  display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: 0 0 auto;
}
[data-portal] .rw-eta { font-size: 12.5px; font-weight: 600; color: var(--gold); }
/* A remote job, marked on the row rather than left to the address line — a
   job with no address is otherwise a row with a gap where a place should be. */
[data-portal] .rw-rem {
  flex: 0 0 auto; padding: 2px 7px;
  background: rgba(237, 237, 240, 0.07); color: var(--n400);
  font-size: 9.5px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase;
}
/* Unread is a DOT on the face — same information, on the thing it is about
   (that professional messaged you) — and EBUCHAT's tab carries the count. */
[data-portal] .rw-unread {
  position: absolute; top: -3px; right: -3px;
  width: 12px; height: 12px; border-radius: var(--radius-pill);
  background: var(--gold); border: 2px solid var(--bg);
}
[data-portal] .amt { font-size: 13px; font-weight: 800; color: var(--text); }
[data-portal] .row-b .chip { max-width: 46vw; }
[data-portal] .row-b .chip,
/* A group heading in the past list. */
[data-portal] .grp {
  padding: 26px var(--sp-wrap-x) 10px;
  background: none; border: 0;
}

/* ═══ STAT TILES ══════════════════════════════════════════════════════════ */
/* A figure and what it counts. Square, hairlined, no fill. */
[data-portal] .stats {
  display: flex; gap: 10px; margin-top: 20px;
  background: none; border-radius: var(--radius-md); overflow: visible;
}
[data-portal] .stat {
  flex: 1 1 0; min-width: 0; padding: 13px 14px;
  background: none; border: 1px solid var(--hair);
}
[data-portal] .stat-v {
  font-size: 20px; font-weight: 800; letter-spacing: -.02em; color: var(--text);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* "Mar 2026" is not a figure and must not be set like one — at 20px it wraps
   and pushes its tile taller than the two beside it. */
[data-portal] .stat-v.stat-sm { font-size: 15px; letter-spacing: -.005em; }
[data-portal] .stat-l {
  margin-top: 5px; font-size: 11.5px; font-weight: 600; color: var(--n300);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* ═══ EMPTY AND ERROR ═════════════════════════════════════════════════════ */
/* LEFT ALIGNED, high on the screen, and no medallion. A centred icon in a
   gold circle is the rounded system's idiom; here an empty screen is a
   sentence and a way forward. */
[data-portal] .empty {
  padding: 56px var(--sp-wrap-x) 40px;
  text-align: left; align-items: flex-start;
}
[data-portal] .empty-circle { display: none; }
[data-portal] .empty-title {
  font-size: 20px; font-weight: 800; letter-spacing: -.015em; color: var(--text);
}
[data-portal] .empty-text {
  font-size: 13px; font-weight: 400; line-height: 1.55; color: var(--n300);
  margin: 8px 0 0; max-width: 42ch;
}
[data-portal] .empty .btn { width: auto; margin-top: 22px; }

/* A skeleton is a hairline box, not a shimmering rounded card. */
[data-portal] .skeleton {
  border-radius: var(--radius-sm); background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
}
[data-portal] .skeleton-card { height: 96px; margin: 0 var(--sp-wrap-x) 12px; }

/* ═══ THE MASTHEAD ════════════════════════════════════════════════════════
   Every tab opens the same way: the brand eyebrow and whatever belongs
   opposite it, a 40px title, one line of counts, then the section rule the
   content hangs from. It is what makes four different screens read as one
   product rather than four pages. */
[data-portal] .mast { padding: 18px var(--sp-wrap-x) 18px; }
/* ONE BRAND, ONE BELL. The app puts its wordmark and its bell in the
   masthead because a native screen has no chrome above it to hold them. This
   page does: a sticky top bar that is always reachable, which is strictly
   better than the app's, where you have to scroll back up to find the bell.
   Carrying both meant EBUTECH appeared twice on the same screen with two
   notification bells under it, which reads as a bug rather than as a system.
   The bar keeps them, the masthead leads with what the screen is, and the
   markup for `.mast-top` is GONE rather than hidden — a zero-size button
   inside #view silently mis-indexed the control sweep. */
/* AN ALL-CAPS TITLE IS SET A SHADE SMALLER, and only a shade.
   The display size is tuned for a word like "Bookings" — mixed case, with
   descenders and counters giving the eye somewhere to rest. EBUCHAT is seven
   CAPITALS with none of that, so at the same nominal size it renders visually
   a little larger.

   `calc()`, NOT `em`, AND THAT IS THE WHOLE BUG. This rule read
   `font-size: .74em`, written to mean "74% of the 40px this element is
   already set to". It does not mean that: `em` in `font-size` resolves
   against the PARENT's font-size, not the element's own. The parent is a
   plain `.mast` inheriting the 14px body size, so EBUCHAT rendered at about
   10px — body copy, in an <h1>, above a screen of conversations. Reported
   from a real phone as "barely visible", which is exactly what it was; the
   optical argument in this comment was describing a size the browser never
   used. Now it is a fraction of the token, so what it says is what renders. */
[data-portal] .mast-title.is-caps {
  font-size: calc(var(--fs-display) * .95);
  letter-spacing: -.015em;
}


[data-portal] .mast-title { margin-top: 6px; }
[data-portal] .mast .mast-title:first-child { margin-top: 14px; }

/* ── A block of content ────────────────────────────────────────────────────
   `.card` names most of the containers this page renders, and in this system
   there are no cards. So it is translated rather than deleted: the fill, the
   border and the shadow go, and what is left is a block that owns the screen
   gutter and is separated from its neighbour by a hairline. Same markup, and
   the box is gone. */
/* THIS RULE IS WHAT MADE BOTH PORTALS LOOK FLAT, and it was doing exactly
   what it said: `background: none; border: 0; box-shadow: none;
   border-radius: 0`, turning every container on the page into an edge-to-edge
   block separated from the next by a hairline. A screenshot of the bookings
   list was four rows of text on one tone with lines between them — a table,
   not an interface — and that is the single change that fixes the most.

   A block is a `base` surface now: its own tone, its own 1px edge, its own
   corner, and AIR between it and the next one instead of a rule. The gutter
   moves outside the block, because a card that pays its own gutter cannot
   have a corner. */
[data-portal] .card {
  background: var(--s-base);
  border: 1px solid var(--e-hair);
  border-radius: var(--radius-lg);
  box-shadow: none;
  padding: 16px var(--sp-card);
  margin: 0 var(--sp-wrap-x) 12px;
}
/* The hairline between blocks is gone: the gap IS the separator now, and a
   line as well as a gap says the same thing twice. */
[data-portal] .card + .card { border-top: 1px solid var(--e-hair); }

/* A CARD THAT IS ITS OWN OBJECT — a booking, a job, a wallet entry — takes
   the level above, so a list of them reads as a stack of things rather than
   as one block subdivided. */
[data-portal] .bcard,
[data-portal] .card-raised {
  background: var(--s-raised);
  border: 1px solid var(--e-line);
  border-radius: var(--radius-lg);
  box-shadow: var(--lift-raised);
}
/* The one construction for "this is the loudest thing here": a faint gold
   ground between two gold rules. It marks without enclosing. */
[data-portal] .card-gold {
  background: var(--gold-soft);
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
  border-left: 0; border-right: 0;
}
[data-portal] .card + .card-gold,
[data-portal] .card-gold + .card { border-top-width: 2px; }

/* A pushed screen's header: back, then what you are looking at. */
[data-portal] .detail-head {
  display: flex; align-items: center; gap: 14px;
  padding: 18px var(--sp-wrap-x) 16px;
}
/* A BARE ARROW, NOT A BOXED BUTTON. Back is the most-used control on a
   detail screen and the least in need of announcing: every app people already
   use draws it as a naked glyph on the left edge. Boxed, it was the loudest
   mark on the screen and it competed with the title beside it for the same
   attention. The 40px tap target stays — it is the border that goes, not the
   size. Pulled left over the gutter so the ARROW, not the box it used to sit
   in, lines up with the content below it. */
[data-portal] .detail-head .icon-btn {
  width: 40px; height: 40px;
  margin-left: -10px;
  border: 0; background: none; color: var(--text);
}
[data-portal] .detail-head .icon-btn:hover { background: rgba(255, 255, 255, 0.06); }
[data-portal] .detail-head .icon-btn svg { width: 22px; height: 22px; }

/* A NAV BAR: arrow on the left edge, title centred on the SCREEN.
   `position: relative` + an absolutely placed arrow, so the title centres on
   the page rather than on the space left over beside the button — which is
   what a flex row would do, and what it looked like. */
[data-portal] .detail-head.is-nav { position: relative; justify-content: center; }
[data-portal] .detail-head.is-nav .icon-btn {
  position: absolute; left: calc(var(--sp-wrap-x) - 8px);
}
[data-portal] .detail-title {
  margin: 0; text-align: center;
  /* Clear of the arrow at both ends, so a long title never runs under it. */
  padding: 0 44px; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* The booking code, as something you TAKE rather than something you read.
   Quiet until it is hovered or focused: it is a utility on a screen whose job
   is the booking, not a call to action. */
[data-portal] .code-copy {
  display: inline-flex; align-items: center; gap: 7px;
  margin-top: 12px; padding: 6px 0;
  min-height: var(--tap);
  background: none; border: 0; border-radius: var(--radius-sm);
  font-family: inherit; color: var(--n300); cursor: pointer;
}
[data-portal] .code-copy-t {
  font-size: 12px; font-weight: 700; letter-spacing: .12em;
  font-variant-numeric: tabular-nums;
}
[data-portal] .code-copy svg { width: 15px; height: 15px; }
[data-portal] .code-copy:hover,
[data-portal] .code-copy:focus-visible { color: var(--text); }

/* The control owns the screen gutter, so it lines up with the rows under it
   whether it sits inside a masthead or directly on the page. */
[data-portal] .seg { margin: 16px var(--sp-wrap-x) 0; }

/* The 76px identity avatar carries two initials; 22px is the shared token and
   is a size too small to fill it. */
[data-portal] .avatar-lg { font-size: 28px; }

/* A section head inside a screen — an eyebrow with air above it. */
[data-portal] .sec { padding: 20px var(--sp-wrap-x) 10px; }

/* A short line of guidance between blocks. `.hint` on its own is flush to the
   glass; this gives it the screen gutter every other block owns. */
[data-portal] .sec-hint { padding: 4px var(--sp-wrap-x) 14px; margin: 0; }

/* ═══ THE PAYMENT PIN ═════════════════════════════════════════════════════
   A FULL SCREEN WITH A KEYPAD, the way a phone asks for its own lock PIN.

   It was a sheet, and on a laptop and a tablet that floated over the account
   with the page showing through — the wrong amount of seriousness by a mile
   for the one screen in this product that handles a secret. A secret gets the
   whole screen and nothing else on it.

   `100dvh`, not `100vh`: on mobile Safari `vh` is the tallest the viewport
   ever gets, so a full-height keypad measured that way pushes its bottom row
   under the browser's own chrome — which on this screen is the delete key. */
[data-portal] .pin-screen {
  position: fixed; inset: 0; z-index: 60;
  display: flex; flex-direction: column; align-items: center;
  min-height: 100dvh;
  padding: env(safe-area-inset-top) 0 calc(env(safe-area-inset-bottom) + 18px);
  background: var(--bg);
  overflow-y: auto;
}
[data-portal] .pin-top {
  width: 100%; max-width: 380px;
  display: flex; justify-content: flex-start;
  padding: 10px var(--sp-wrap-x) 0;
}
[data-portal] .pin-x { border: 0; background: none; color: var(--n300); }

/* THE FREE SPACE IS SPLIT, not dumped in one place. With only the pad pushed
   to the floor, everything else stayed clamped to the ceiling and the screen
   was a title, four dots, and 600px of black. An `auto` at both ends divides
   the slack evenly: the close button on the ceiling, the title and dots a
   third of the way down, the keys on the floor under a thumb. */
[data-portal] .pin-head {
  text-align: center; padding: 18px var(--sp-wrap-x) 0; max-width: 380px;
  margin-top: auto;
}
[data-portal] .pin-lock {
  display: inline-flex; align-items: center; justify-content: center;
  width: 46px; height: 46px; margin-bottom: 18px;
  background: var(--gold-soft); color: var(--gold);
}
[data-portal] .pin-lock svg { width: 21px; height: 21px; }
[data-portal] .pin-title {
  margin: 0; font-size: 22px; font-weight: 800; letter-spacing: -.02em; color: var(--text);
}
[data-portal] .pin-sub {
  margin: 8px 0 0; font-size: 13px; line-height: 1.5; color: var(--n300);
}

/* FOUR DOTS AND NOTHING ELSE. A PIN pad shows how many digits are in, never
   which — so there is no field here to read back, shoulder-surf or autofill. */
[data-portal] .pin-dots {
  display: flex; gap: 18px; margin: 30px 0 0;
}
/* A SLOT, not a checkbox. Four empty outlined squares read as a form asking
   you to tick something — the filled ground says "four things go here" and the
   gold says how many are in. */
[data-portal] .pin-dot {
  width: 14px; height: 14px;
  border: 1px solid var(--rule); background: var(--surface-strong);
  transition: background var(--motion-tap) ease, border-color var(--motion-tap) ease;
}
[data-portal] .pin-dot.is-on { background: var(--gold); border-color: var(--gold); }
[data-portal] .pin-dots.is-wrong .pin-dot { border-color: var(--danger); }
@media (prefers-reduced-motion: reduce) {
  [data-portal] .pin-dot { transition: none; }
}
/* Reserved whether or not there is an error, so the keypad does not jump up
   the screen the moment somebody gets a digit wrong. */
[data-portal] .pin-error {
  min-height: 20px; margin: 14px 0 0;
  font-size: 12.5px; color: var(--danger); text-align: center;
}

/* UNDER THE THUMB, not floating in the middle of the screen. `margin-top: auto`
   pushes the pad to the bottom of the fixed-height screen and leaves the slack
   ABOVE it, between the dots and the keys — which is where slack belongs on a
   one-handed screen. It sat centred, with 400px of empty black under the last
   row, which is the part of a phone a thumb actually reaches. */
[data-portal] .pin-pad {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
  width: 100%; max-width: 300px;
  margin-top: auto; padding: 0 var(--sp-wrap-x);
}
/* 64px minimum. A keypad is aimed at with a thumb, in a hurry, and often
   one-handed — the tap floor everything else in this product uses is a
   MINIMUM, not a target.
   NO BORDERS. Twelve outlined boxes is a calculator; a phone asking for its own
   lock PIN draws the digits and nothing else, and the brief was "like inputing
   your phone's pin". The tap target is unchanged — it is the whole cell, which
   is what the press fill shows. */
[data-portal] .pin-key {
  min-height: 70px;
  display: flex; align-items: center; justify-content: center;
  border: 0; background: none;
  color: var(--text); font-family: inherit;
  font-size: 27px; font-weight: 600; font-variant-numeric: tabular-nums;
  cursor: pointer; user-select: none;
  -webkit-tap-highlight-color: transparent;
}
[data-portal] .pin-key:hover { background: rgba(255, 255, 255, 0.05); }
[data-portal] .pin-key:active { background: rgba(255, 255, 255, 0.11); }
[data-portal] .pin-key.is-blank { pointer-events: none; }
[data-portal] .pin-del { color: var(--n300); }
[data-portal] .pin-del svg { width: 22px; height: 22px; }

/* THE PAD BEFORE IT KNOWS WHAT IT IS ASKING FOR — a refresh or a bookmark on
   `#/payment-pin`, while the wallet says whether this is a first PIN or a
   change. Everything but the close control is dimmed and inert: the shape is
   already correct, so when the answer lands only the title changes and the
   keys come up. The alternative, guessing "Choose a PIN" and correcting
   itself, showed a create flow to somebody who already had a PIN. */
[data-portal] .pin-screen.is-waiting .pin-lock,
[data-portal] .pin-screen.is-waiting .pin-title,
[data-portal] .pin-screen.is-waiting .pin-sub,
[data-portal] .pin-screen.is-waiting .pin-dots,
[data-portal] .pin-screen.is-waiting .pin-pad {
  opacity: 0.4;
  transition: opacity var(--motion-surface) var(--ease);
}
[data-portal] .pin-screen.is-waiting .pin-key { cursor: default; }
[data-portal] .pin-screen.is-waiting .pin-key:hover { background: none; }
[data-portal] .pin-busy {
  margin-top: 18px; font-size: 12.5px; color: var(--n300);
}

/* On a laptop there is no thumb and no bottom edge to reach for, so the pad
   stops hugging the floor of a 900px window and centres with everything else —
   `margin-top: auto` is a phone rule, not a desktop one. */
@media (min-width: 760px) {
  [data-portal] .pin-screen { justify-content: center; }
  [data-portal] .pin-head { margin-top: 0; }
  [data-portal] .pin-pad { gap: 12px; margin-top: 10px; margin-bottom: auto; }
}

/* ═══ THE ENDING ══════════════════════════════════════════════════════════
   A booking that is finished and settled gets one full screen saying so, once.
   See jobDoneOverlay in account-page.js for when it plays and why it is `paid`
   rather than `completed`.

   THE MARK IS GREEN AND NOT GOLD, and that is the design rather than a
   decoration: the accent is reserved for what is LIVE and what is OWED, and a
   job that is done and paid for is neither. Gold here would say "this still
   needs you" on the one screen whose entire message is that nothing does. It
   is the only green in the product and it appears nowhere else. */
[data-portal] .jd {
  position: fixed; inset: 0; z-index: 70;
  display: flex; flex-direction: column; align-items: center;
  min-height: 100dvh;
  padding: env(safe-area-inset-top) 0 calc(env(safe-area-inset-bottom) + 18px);
  background: var(--bg);
  overflow-y: auto;
  animation: jd-in var(--motion-screen) var(--ease) both;
}
@keyframes jd-in { from { opacity: 0; } to { opacity: 1; } }

[data-portal] .jd-top {
  width: 100%; max-width: 420px;
  display: flex; justify-content: flex-start;
  padding: 10px var(--sp-wrap-x) 0;
}

[data-portal] .jd-body {
  width: 100%; max-width: 420px;
  flex: 1 1 auto;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: 20px var(--sp-wrap-x);
  text-align: center;
}

[data-portal] .jd-mark {
  width: 86px; height: 86px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: var(--radius-pill);
  background: rgba(47, 191, 113, 0.12);
  border: 1px solid rgba(47, 191, 113, 0.24);
  color: #2fbf71;
  margin-bottom: 26px;
  animation: jd-mark var(--motion-surface) var(--ease) both;
}
[data-portal] .jd-mark svg { width: 38px; height: 38px; stroke-width: 2.2; }
@keyframes jd-mark {
  from { opacity: 0; transform: scale(0.72); }
  to { opacity: 1; transform: scale(1); }
}
/* An ambient flourish STOPS under the setting rather than speeding up. */
@media (prefers-reduced-motion: reduce) {
  [data-portal] .jd,
  [data-portal] .jd-mark { animation: none; }
}

[data-portal] .jd-title {
  margin: 0;
  font-size: 27px; font-weight: 800; letter-spacing: -0.6px;
  color: var(--text);
}
[data-portal] .jd-sub {
  margin: 9px 0 0;
  font-size: 14px; line-height: 1.5; color: var(--n300);
  max-width: 320px;
}

/* The professional, on a card: one object made of four facts, and the surface
   is what says so. */
[data-portal] .jd-pro {
  display: flex; align-items: center; gap: 13px;
  align-self: stretch; margin-top: 34px;
  padding: 14px;
  text-align: left;
  background: var(--s-raised);
  border: 1px solid var(--e-line);
  border-radius: var(--radius-lg);
  box-shadow: var(--lift-raised);
}
[data-portal] .jd-pro-body { min-width: 0; flex: 1; }
[data-portal] .jd-pro-name {
  display: block;
  font-size: 15.5px; font-weight: 800; letter-spacing: -0.2px; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
[data-portal] .jd-pro-meta {
  display: flex; align-items: center; gap: 5px;
  margin-top: 4px;
  font-size: 12px; color: var(--n300);
}
[data-portal] .jd-shield { width: 13px; height: 13px; color: #2fbf71; flex: 0 0 auto; }
[data-portal] .jd-star {
  display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto;
  padding: 5px 9px;
  background: var(--s-base);
  border-radius: var(--radius-sm);
  font-size: 13px; font-weight: 800; color: var(--text);
  font-variant-numeric: tabular-nums;
}
[data-portal] .jd-star svg { width: 13px; height: 13px; color: var(--gold); }

[data-portal] .jd-actions {
  width: 100%; max-width: 420px;
  padding: 0 var(--sp-wrap-x);
  display: grid; gap: 4px;
}
/* "Not now" is a WAY OUT, not a second offer. `.btn-quiet` draws a bordered
   box the same size as the gold one, and two equally weighted buttons make the
   reader choose between two things when only one of them is the point. */
[data-portal] .jd-actions .btn-quiet {
  border: 0; background: none;
  color: var(--n300); font-weight: 700;
}
[data-portal] .jd-actions .btn-quiet:hover { color: var(--text); background: none; }

/* ═══ EBUPAY ══════════════════════════════════════════════════════════════
   THE NUMBER, THE TWO ACTIONS, THEN THE HISTORY.

   That order is what the crypto wallets get right and it is the only thing
   borrowed here: the balance enormous and alone at the top, the two things you
   can do with it directly under a thumb, and the ledger below the fold. What is
   NOT borrowed is the look — no gradient, no glass, no rounded cards. This is
   the same flat square gold-on-black system as every other EBUTECH screen, so
   the wallet reads as part of the product rather than as something bolted on.

   The balance is set in the display size and then some. It is the whole reason
   somebody opened this screen, and a wallet that renders your money at the
   same weight as a row label has buried the one thing it exists to show. */
[data-portal] .wal-head {
  padding: 26px var(--sp-wrap-x) 24px;
  background:
    linear-gradient(180deg, var(--gold-soft) 0%, transparent 78%);
}
/* THE SAME NUMBER AS THE APP, and that is the whole sizing rule. It was
   clamp(40px, 12vw, 58px) and grew to 58px on a laptop while the app's balance
   sat at a flat 40 — one client, two devices, two different-looking wallets.
   The clamp stays because a 320px phone still has to fit ₦1,234,567.89, but it
   now tops out where the app does instead of overshooting it by half again.

   40px is `--fs-display`, the size of a screen title — and EBUPAY is the one
   screen with no title, which is what makes that right rather than timid: the
   balance is not competing with a heading, it IS the heading. The clamp only
   ever bites below a 334px viewport, where ₦1,234,567.89 would otherwise run
   off the edge. */
[data-portal] .wal-amount {
  font-size: clamp(34px, 12vw, 40px);
  font-weight: 800; letter-spacing: -.035em; line-height: 1.05;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  margin-top: 10px;
}
/* Before the first response. A wallet that flashes ₦0.00 and then corrects
   itself has told somebody their money is gone, for one frame. */
[data-portal] .wal-amount.is-loading {
  color: transparent; background: var(--surface-strong);
  max-width: 62%; border-radius: var(--radius-sm);
}
[data-portal] .wal-sub { font-size: 12.5px; color: var(--n300); margin-top: 10px; }

/* Two actions, equal width, thumb-height. The gold one is Add money because
   that is what an empty wallet needs; paying is reached from the booking being
   paid for, which is where somebody already is when they decide to. */
[data-portal] .wal-actions {
  display: flex; gap: 10px;
  padding: 0 var(--sp-wrap-x) 22px;
}
[data-portal] .wal-act {
  flex: 1; display: flex; align-items: center; justify-content: center; gap: 9px;
  min-height: 52px; padding: 0 14px;
  border: 1px solid var(--rule); background: none;
  color: var(--text); font-family: inherit;
  font-size: 14.5px; font-weight: 800; letter-spacing: -.01em;
  cursor: pointer;
}
[data-portal] .wal-act svg { width: 17px; height: 17px; }
[data-portal] .wal-act.is-primary {
  background: var(--gold); border-color: var(--gold); color: #17140C;
}
[data-portal] .wal-act:not(.is-primary):hover { background: rgba(255, 255, 255, 0.05); }

/* One movement. The amount is the loudest thing in the row because a ledger is
   read down its right edge — you scan the numbers, not the labels. */
[data-portal] .wal-row { cursor: default; }
[data-portal] .wal-delta {
  flex: 0 0 auto; font-size: 14.5px; font-weight: 800;
  font-variant-numeric: tabular-nums; letter-spacing: -.01em;
}
[data-portal] .wal-delta.is-in { color: var(--gold); }
[data-portal] .wal-delta.is-out { color: var(--text); }
/* An attempt that never moved money is struck through rather than hidden. A
   client who tried to pay and could not is a thing support has to be able to
   see, and a row that silently vanishes is a row nobody can ask about. */
[data-portal] .wal-delta.is-void {
  color: var(--n200); text-decoration: line-through;
}

/* ══ THE WHOLE LEDGER, AND ONE RECEIPT ═══════════════════════════════════
 *
 * Recent activity is not a history. "How much did I spend in August", "where
 * did that ₦20,000 go" and "find PSK-…" are unanswerable from a strip of the
 * newest rows, and a client with two years of EBUPAY use could not reach their
 * own eleventh page. A balance nobody can audit is a balance people stop
 * trusting, and this holds real money.
 *
 * The same shape as the app's wallet-history screen, on purpose: somebody who
 * checks their wallet on a phone and again on a laptop must not learn it
 * twice. Flat, hairlined, gold only on money coming IN.
 */
[data-portal] .wal-filters { padding: 6px var(--sp-wrap-x) 14px; }
/* A PILL, and a WELL. It was a 10px corner on a 46px control — a rectangle
   with the corners filed off — and a lighter fill than its surroundings, so it
   read as a raised box rather than as something to type into. `sunken` is
   darker than the ground for exactly that reason. */
[data-portal] .wal-search {
  display: flex; align-items: center; gap: 9px;
  padding: 11px 18px; border: 1px solid var(--e-hair);
  border-radius: var(--radius-pill); background: var(--s-sunken);
}
[data-portal] .wal-search svg { width: 15px; height: 15px; color: var(--n300); flex: 0 0 auto; }
/* 16px, or iOS zooms the whole page the moment this is focused. */
[data-portal] .wal-search input {
  flex: 1; min-width: 0; border: 0; background: none; outline: none;
  font: inherit; font-size: 16px; color: var(--text);
}

/* A row of windows. Horizontally scrollable rather than wrapped: six chips
   wrapping to two lines on a narrow phone pushes the list off the screen. */
[data-portal] .wal-chips {
  display: flex; gap: 8px; margin-top: 12px;
  overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
[data-portal] .wal-chips::-webkit-scrollbar { display: none; }
[data-portal] .wal-chip {
  /* A TAP TARGET, not a label. It was 44x34: wide enough and four short.
     `inline-flex` is what lets the min-height do anything — on a plain button
     the extra height would sit under the text rather than centring it. */
  flex: 0 0 auto; display: inline-flex; align-items: center;
  min-height: var(--tap); padding: 7px 13px;
  border: 1px solid var(--hair); border-radius: var(--radius-pill);
  background: none; color: var(--n400);
  font: inherit; font-size: 12.5px; font-weight: 600; white-space: nowrap;
  cursor: pointer; transition: background-color var(--motion-control) ease, color var(--motion-control) ease;
}
[data-portal] .wal-chip:hover { background: rgba(255, 255, 255, .05); }
[data-portal] .wal-chip.is-on {
  background: var(--gold); border-color: var(--gold);
  color: var(--on-gold); font-weight: 700;
}
/* FOUR SEGMENTS THAT HAVE TO FIT A 360px PHONE. `[data-portal] .seg` is
   `inline-flex`, so it is sized by its content, and "Everything · Money in ·
   Jobs paid · Refunds" at 18px of padding a side came to 368px inside a 328px
   wrap. It did not clip; it widened the DOCUMENT, which is why the check
   reported the tab bar running 48px past the viewport on this screen and
   nowhere else — the bar is fixed to a layout viewport something else had
   stretched. The symptom was three elements away from the cause.

   So this one fills its row and lets the segments share it. `min-width: 0` is
   the load-bearing half: a flex item defaults to `auto`, which refuses to
   shrink below its text. */
[data-portal] .wal-seg {
  margin-top: 10px;
  display: flex; width: 100%; max-width: 100%;
}
[data-portal] .wal-seg .seg-btn {
  flex: 1 1 0; min-width: 0;
  justify-content: center; padding-left: 8px; padding-right: 8px;
}

/* THE TOTALS FOR THE WINDOW. The balance is deliberately not among them: it is
   a NOW figure, not a figure for August, and standing it beside two window
   totals is how it gets read as one. */
[data-portal] .wal-totals {
  display: flex; flex-wrap: wrap; gap: 22px;
  padding: 0 var(--sp-wrap-x) 16px;
}
[data-portal] .wal-total { display: flex; flex-direction: column; gap: 3px; }
[data-portal] .wal-total-n {
  font-size: 17px; font-weight: 700; color: var(--text);
  font-variant-numeric: tabular-nums; letter-spacing: -.012em;
}
[data-portal] .wal-total-n.is-in { color: var(--gold); }
/* Refused attempts are COUNTED, never summed — money that did not move must
   not read like money that did — so this one is grey and carries a word. */
[data-portal] .wal-total-n.is-quiet { color: var(--n300); }

[data-portal] .wal-more { padding: 18px var(--sp-wrap-x) 30px; }

/* ── One transaction ───────────────────────────────────────────────────── */
[data-portal] .wal-rec-head { padding: 22px var(--sp-wrap-x) 20px; }
[data-portal] .wal-rec-amt {
  font-size: 38px; font-weight: 740; letter-spacing: -.034em;
  color: var(--text); font-variant-numeric: tabular-nums; margin-top: 8px;
}
[data-portal] .wal-rec-amt.is-in { color: var(--gold); }
[data-portal] .wal-rec-amt.is-void { color: var(--n200); text-decoration: line-through; }
[data-portal] .wal-rec-when { font-size: 13px; color: var(--n300); margin-top: 8px; }
[data-portal] .wal-rec-why {
  padding: 0 var(--sp-wrap-x) 18px;
  font-size: 13.5px; line-height: 1.5; color: var(--n400);
}
[data-portal] .wal-rec-note { padding-top: 4px; }
[data-portal] .wal-rec-note .wal-rec-why { padding-top: 6px; }
[data-portal] .wal-rec-rows { padding: 6px var(--sp-wrap-x) 8px; }
[data-portal] .wal-rec-refs { padding: 18px var(--sp-wrap-x) 34px; }
/* The reference is a credential: one string, never broken across two lines,
   with the copy control beside it — reading 24 characters down a phone line is
   how the wrong transaction gets looked at. */
[data-portal] .wal-rec-ref {
  display: flex; align-items: center; gap: 8px; margin-top: 6px;
  font-size: 14px; font-weight: 600; letter-spacing: .02em; color: var(--text);
  word-break: break-all;
}

/* A section head that carries a way through, rather than only a label. */
[data-portal] .sec-row {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
[data-portal] .sec-more {
  border: 0; background: none; padding: 0; cursor: pointer;
  font: inherit; font-size: 12.5px; font-weight: 700; color: var(--gold);
}
[data-portal] .sec-more:hover { text-decoration: underline; }

/* The funding account, inside the Add money sheet. The number is the whole
   point of that sheet, so it is set like the balance: big, tabular, tappable
   to copy in one go rather than something to squint at and retype. */
[data-portal] .wal-acct {
  margin-top: 16px; padding: 18px;
  border: 1px solid var(--gold); background: var(--gold-soft);
  text-align: center;
}
[data-portal] .wal-acct-bank {
  font-size: 11px; font-weight: 800; letter-spacing: .14em;
  text-transform: uppercase; color: var(--gold);
}
[data-portal] .wal-acct-no {
  display: inline-flex; align-items: center; gap: 10px;
  margin: 10px 0 6px;
  font-size: 30px; font-weight: 800; letter-spacing: .06em;
  font-variant-numeric: tabular-nums; color: var(--text);
  cursor: pointer;
}
[data-portal] .wal-acct-no svg { width: 17px; height: 17px; color: var(--gold); }
[data-portal] .wal-acct-name { font-size: 12.5px; color: var(--n300); }

/* ── EBUPAY on the booking being paid for ─────────────────────────────────
   The first rail in the payment block, and the only one that finishes on this
   screen: a transfer sends somebody to a banking app and a card sends them to
   Paystack's, but a balance is four digits and done. So it leads, and the rule
   under it separates "pay with what you already have" from "go and fetch it
   from somewhere else". */
[data-portal] .pay-rail { margin-top: 14px; }
[data-portal] .pay-rail .btn svg { width: 17px; height: 17px; }
/* Short of the bill. Quieter, because it is information rather than an
   action — the action inside it is the small one. */
[data-portal] .pay-rail.is-short {
  padding: 14px; border: 1px solid var(--hair);
}
[data-portal] .pay-rail.is-short .btn { margin-top: 0; }

/* Pay a job: the balance, above the list of what is owed. */
[data-portal] .pay-bal {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 14px; padding: 14px var(--sp-wrap-x) 16px;
  border-bottom: 1px solid var(--hair);
}
[data-portal] .pay-bal .lbl {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 11px; font-weight: 800; letter-spacing: .12em;
  text-transform: uppercase; color: var(--n300);
}
[data-portal] .pay-bal .val {
  font-size: var(--fs-figure); font-weight: var(--fw-figure);
  font-variant-numeric: tabular-nums; letter-spacing: -.02em;
}

/* The sheet's own body, for the markup a caller composes (an account number,
   a pair of PIN fields) as opposed to the plain sentence `text` renders. */
[data-portal] .sheet-body { margin-top: 6px; }
[data-portal] .sheet-body .lbl {
  display: block; margin: 14px 0 6px;
  font-size: 11px; font-weight: 800; letter-spacing: .12em;
  text-transform: uppercase; color: var(--n300);
}
/* The sheet's own input, which is now an AMOUNT rather than a PIN — the PIN
   moved to its own full screen. So the wide tracking is gone: it was there to
   make four dots countable at a glance and it makes "5000" look like a serial
   number. Big, centred and tabular, and never under 16px or iOS zooms the page
   on focus and takes the sheet off the screen with it.

   `.sheet [data-in]`, NOT `.sheet-body input.field`. sheet() renders `input`
   as a SIBLING of `.sheet-body` — the body holds the caller's markup, the
   input is its own block after it — so the descendant selector matched
   nothing and none of this applied. It rendered at the plain 15px field size
   for as long as it existed, and only check-dead-css noticed. */
[data-portal] .sheet [data-in] {
  width: 100%; font-size: 22px; text-align: center;
  font-variant-numeric: tabular-nums; letter-spacing: -.01em;
}

/* ═══ CUSTOMER CARE ═══════════════════════════════════════════════════════
   ONE QUESTION AT A TIME.

   Native <details>, not a scripted accordion — it is keyboard-operable for
   free, the browser's own Find-in-page reaches inside a CLOSED answer, and it
   needs no JavaScript, which matters on a page whose whole view re-renders
   every time a background poll comes back.

   The question goes to full strength when it opens: it is the heading of the
   answer underneath it then, not one item in a list of many. */
[data-portal] .faq { border-bottom: 1px solid var(--hair); }
[data-portal] .group-body > .faq:last-child { border-bottom: none; }
[data-portal] .faq-q {
  display: flex; align-items: center; gap: 12px;
  min-height: var(--tap); padding: 15px var(--sp-wrap-x);
  cursor: pointer; list-style: none;
  font-size: 14.5px; font-weight: 700; line-height: 1.35; color: var(--n500);
}
/* Safari draws its own disclosure triangle unless BOTH of these are set. */
[data-portal] .faq-q::-webkit-details-marker { display: none; }
[data-portal] .faq-q::marker { content: ''; }
[data-portal] .faq[open] > .faq-q { color: var(--text); }
[data-portal] .faq-chev {
  margin-left: auto; flex: 0 0 auto; display: inline-flex; color: var(--n200);
  transition: transform var(--motion-control) ease, color var(--motion-control) ease;
}
[data-portal] .faq-chev svg { width: 15px; height: 15px; }
[data-portal] .faq[open] > .faq-q .faq-chev { transform: rotate(90deg); color: var(--gold); }
@media (prefers-reduced-motion: reduce) {
  [data-portal] .faq-chev { transition: none; }
}
[data-portal] .faq-a {
  padding: 0 44px 17px var(--sp-wrap-x);
  font-size: 13.5px; line-height: 1.6; color: var(--n300);
}
/* The chevron sits in the question's right gutter, so the answer stops short
   of it rather than running under it. On a phone that gutter is most of the
   line, so it comes back to the normal wrap. */
@media (max-width: 420px) {
  [data-portal] .faq-a { padding-right: var(--sp-wrap-x); }
}


/* ═══ HOME ════════════════════════════════════════════════════════════════
   A FEED, led by whatever is live.

   NO MAP GROUND. The version this replaces opened on a stylised city with a
   sheet pinned over it — Bolt's home, because Bolt's question is "where are
   you going". Ours is "what do you need done", and the answer is not a
   place. Worse, the map could never plot anybody: no coordinate is on the
   wire in either direction, so every road on it was decoration pretending to
   be information on the one screen whose job is to say truthfully where
   somebody is. A route strip still appears while a professional is genuinely
   travelling, carrying the real measured distance.

   NO HORIZONTAL RAIL. Live jobs used to swipe sideways behind a gesture
   nothing announced, and a rail hides its own length. Now: the most urgent
   job in full, the next few as rows, and a link to Bookings for the rest. */

/* Verify your email. A block, not a modal — a dialog on every visit is
   something people learn to dismiss without reading. */
[data-portal] .prompt {
  display: flex; align-items: center; gap: 14px;
  width: 100%; padding: 16px var(--sp-wrap-x);
  background: none; border: 0; text-align: left;
  font-family: inherit; color: var(--text); cursor: pointer;
}
[data-portal] .prompt:hover { background: rgba(255, 255, 255, 0.04); }
[data-portal] .prompt-ic {
  width: 42px; height: 42px; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--gold-soft); color: var(--gold);
}
[data-portal] .prompt-ic svg { width: 19px; height: 19px; }
[data-portal] .prompt-b { min-width: 0; flex: 1; }
[data-portal] .prompt-t { font-size: 15px; font-weight: 800; letter-spacing: -.01em; }
[data-portal] .prompt-s { font-size: 12.5px; color: var(--n300); margin-top: 2px; }
/* The figure on the right of a prompt row — what is owed. A CLASS, not an
   inline `font-size`: a size written into a JS template string is invisible to
   the type scale and never shrinks with it, which is what
   scripts/check-type-floors.js exists to catch. It caught this one. */

/* `.prompt-amt` was here: the 20px figure on the right of Home's owed strip.
   It went with the strip. The worker's payout estimate is `.wowed-amt` now,
   set at 34px on the page with no box round it, because the number IS the
   design. Deleted rather than kept "in case": CSS that matches nothing is a
   claim about markup nobody can check, and check-dead-css found it. */

/* ── The live job ──────────────────────────────────────────────────────────
   The loudest thing on the screen, and it is built the way this system
   builds a loud thing: content on the ground, no box. */
[data-portal] .lv { padding: 4px 0 20px; }
[data-portal] .lv-body { padding: 0 var(--sp-wrap-x); }
[data-portal] .lv-top {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  margin-bottom: 14px;
}
[data-portal] .lv-code {
  font-size: 11.5px; font-weight: 600; letter-spacing: .06em; color: var(--n200);
  font-variant-numeric: tabular-nums;
}
[data-portal] .lv-figrow { display: flex; align-items: baseline; gap: 10px; }
/* Flat gold. The gloss painted this through a cream→gold→bronze gradient with
   background-clip:text so the number read "as metal"; at 44px on a flat
   ground the weight already carries it, and a gradient numeral is the one
   thing on the screen that could not be redrawn as a rule. */
[data-portal] .lv-fig {
  font-size: var(--fs-live); font-weight: 800; line-height: .95;
  letter-spacing: -.035em; color: var(--gold);
  font-variant-numeric: tabular-nums;
}
[data-portal] .lv-unit { font-size: 16px; font-weight: 600; color: var(--text); }
[data-portal] .lv-head {
  font-size: 30px; font-weight: 800; line-height: 1.05; letter-spacing: -.025em;
}
[data-portal] .lv-sub {
  margin-top: 8px; font-size: 13px; font-weight: 400; color: var(--n300);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* The arrival code cue, only while somebody is at the door. The faint gold
   ground between two gold rules — this system's construction for the one
   thing that matters most right now. */
/* WIDTH: AUTO, not 100%. The negative margins are what pull this out to the
   screen edges, and they can only do that if the width is left to the layout:
   `width: 100%` pinned the box to the CONTENT width and then shifted the whole
   thing 18px left, so it bled past the left edge and stopped 18px short of the
   right one. It is a flex container, so auto fills. */
/* The arrival cue, full-bleed like .isearch and for the same reason — see the
   long note there. `width: auto` was the previous attempt at this and it does
   not work on a BUTTON: Chrome sizes a form control to its content whatever
   `display: flex` says, so the gold bar was 260px of "Show your arrival code"
   floating in a 760px column instead of the band across the card it is drawn
   as. The width is stated as the column plus both pulls. */
[data-portal] .lv-cue {
  display: flex; align-items: center; gap: 10px;
  width: calc(100% + var(--sp-wrap-x) * 2);
  margin-top: 16px; padding: 13px var(--sp-wrap-x);
  margin-left: calc(var(--sp-wrap-x) * -1); margin-right: calc(var(--sp-wrap-x) * -1);
  background: var(--gold-soft);
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
  border-left: 0; border-right: 0;
  font-family: inherit; font-size: 14px; font-weight: 800; color: var(--gold-hi);
  text-align: left; cursor: pointer;
}
[data-portal] .lv-cue svg { width: 17px; height: 17px; flex: 0 0 auto; }
[data-portal] .lv-cue span { flex: 1; }

[data-portal] .lv-pro {
  display: flex; align-items: center; gap: 12px; margin-top: 18px;
}
[data-portal] .lv-pro-n { font-size: 14.5px; font-weight: 800; letter-spacing: -.01em; }
[data-portal] .lv-pro-m { font-size: 12px; color: var(--n300); margin-top: 2px; }
[data-portal] .lv-chat {
  position: relative; width: 42px; height: 42px; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 1px solid var(--rule); color: var(--text); cursor: pointer;
}
[data-portal] .lv-chat svg { width: 18px; height: 18px; }
[data-portal] .lv-chat .dot { position: absolute; top: 6px; right: 6px; }

/* ── The progress rail ─────────────────────────────────────────────────────
   One SQUARE segment per real lifecycle step, filled up to where the job has
   got to. Segments, not a continuous bar: a bar at 43% invites the question
   "43% of what", and the honest answer is "three steps out of seven". */
[data-portal] .tks { display: flex; gap: 4px; margin-top: 14px; }
[data-portal] .tk {
  flex: 1; height: 3px; border-radius: var(--radius-xs);
  background: rgba(237, 237, 240, 0.13);
}
[data-portal] .tk.on,
[data-portal] .tk.now { background: var(--gold); }
[data-portal] .tks-cap {
  display: flex; justify-content: space-between; gap: 12px; margin-top: 8px;
  font-size: 11.5px; font-weight: 600; letter-spacing: .1em;
  text-transform: uppercase; color: var(--n200);
}

/* ── The route strip ───────────────────────────────────────────────────────
   IT PLOTS NOBODY, and that has to stay true however much it looks like it
   does. The roads and both markers are a fixed drawing; what is REAL is the
   number in the chip, measured server-side on actual roads. Which is why it
   is only ever rendered while a professional is genuinely on the way. */
[data-portal] .strip {
  position: relative; height: 132px; overflow: hidden;
  background: #101218; margin-bottom: 18px;
}
/* DIRECT CHILD. `.strip svg` also matched the pin inside .strip-chip, which
   sits inside the band — so a 24px glyph was stretched to 402×132 and painted
   a white chevron across the whole strip. */
[data-portal] .strip > svg { width: 100%; height: 100%; display: block; }
[data-portal] .strip-chip svg { width: 14px; height: 14px; flex: 0 0 auto; color: var(--gold); }
[data-portal] .strip-chip {
  position: absolute; left: var(--sp-wrap-x); bottom: 12px;
  display: inline-flex; align-items: center; gap: 8px;
  padding: 7px 12px; background: var(--bg);
  font-size: 12px; font-weight: 600; color: var(--text);
}

/* A live job below the hero, as a row. */
[data-portal] .lrow {
  display: flex; align-items: center; gap: 14px; width: 100%;
  padding: 13px var(--sp-wrap-x);
  background: none; border: 0; text-align: left;
  font-family: inherit; color: var(--text); cursor: pointer;
}
[data-portal] .lrow:hover { background: rgba(255, 255, 255, 0.04); }
/* `display: block` because the markup builds these from spans — a <button>
   may not contain a <div>, and a row of inline spans runs the name and the
   professional together on one line. */
[data-portal] .lrow-n {
  display: block; font-size: 14.5px; font-weight: 800; letter-spacing: -.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
[data-portal] .lrow-m {
  display: block; font-size: 12px; color: var(--n300); margin-top: 2px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
[data-portal] .lrow .tag { flex: 0 0 auto; }
/* Same reason, for every row built out of spans. */
[data-portal] .row-body > span,
[data-portal] .prompt-b > span { display: block; }

/* "See all 6 in Bookings" */
[data-portal] .more {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 14px var(--sp-wrap-x);
  background: none; border: 0; text-align: left;
  font-family: inherit; font-size: 13px; font-weight: 800;
  color: var(--gold); cursor: pointer;
}
[data-portal] .more svg { width: 16px; height: 16px; }

/* ── Browse ────────────────────────────────────────────────────────────────
   A grid of categories, each a hairline box. No counts on the tiles: a
   number next to "Electrical" tells a client how many rows are in our
   database, which is our business and not theirs. */
/* THE HAIRLINES COME FROM THE TILES, NOT FROM A BACKGROUND BEHIND THEM.
 *
 * This grid used to draw its lines the cheap way: `gap: 1px` over a container
 * painted `--hair`, so the background showed through the seams. It looks
 * identical while the last row is full — and paints a SOLID GREY BLOCK in
 * every cell that has no tile in it the moment it is not.
 *
 * Which is not a rare state. Home shows seven categories plus a More tile;
 * eight tiles in a three-column grid leave one cell empty, so every screen
 * wide enough for three columns had an ash rectangle sitting in the grid. It
 * survived because the fixture had five categories — six tiles, which divides
 * evenly into both two and three columns — so the stub drew a nicer grid than
 * the product did.
 *
 * Now each tile carries its own top and left rule and the container is just
 * background. An incomplete row paints nothing, at ANY tile count and any
 * number of columns, which is the property that matters: the next category
 * the admin adds must not be able to bring this back. */
[data-portal] .cats {
  display: grid; grid-template-columns: repeat(2, 1fr);
  gap: 0; background: var(--bg);
  border-top: 1px solid var(--hair); border-bottom: 1px solid var(--hair);
}
[data-portal] .cat {
  display: flex; align-items: center; gap: 12px;
  padding: 16px var(--sp-wrap-x);
  background: var(--bg); text-align: left;
  border: 0;
  /* Between rows and between columns. The container already draws the outer
     top and bottom, so the first row and the first column drop theirs. */
  border-top: 1px solid var(--hair); border-left: 1px solid var(--hair);
  font-family: inherit; color: var(--text); cursor: pointer; min-height: 62px;
}
[data-portal] .cat:nth-child(-n+2) { border-top: 0; }
[data-portal] .cat:nth-child(2n+1) { border-left: 0; }
[data-portal] .cat:hover { background: rgba(255, 255, 255, 0.04); }
[data-portal] .cat-ic {
  width: 34px; height: 34px; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(255, 255, 255, 0.05); color: var(--n500); border-radius: var(--radius-sm);
}
[data-portal] .cat-ic svg { width: 18px; height: 18px; }
[data-portal] .cat-t { font-size: 13.5px; font-weight: 700; letter-spacing: -.01em; min-width: 0; }

/* The search box that opens Book a service.
 *
 * IT BLEEDS TO THE SCREEN EDGES, and the width says so out loud.
 *
 * It used to be `width: 100%` with the pull applied as an inline style on the
 * element (`margin: 0 calc(var(--sp-wrap-x) * -1)`). A fixed 100% width does
 * not stretch into a negative margin — it stays the width of the padded column
 * and SLIDES LEFT instead. So the bar sat flush with the category tiles on the
 * left and fell 48px short of them on the right: out of line with everything
 * above and below it, which is exactly what it looked like.
 *
 * `width: auto` is the fix for a div and NOT for this: a <button> is a form
 * control, and Chrome sizes one to its content whatever `display: flex` says.
 * That shrank the bar to the width of its placeholder text.
 *
 * So the width is stated as what it actually is — the column plus both pulls —
 * and the margin lives here beside it rather than in a template, so the two
 * halves of one measurement cannot drift apart again. */
/* A PILL, AND THE FIRST THING ON THE SCREEN.
   It was a full-bleed band pulled out to both edges with a hairline above and
   below — the shape of a table row, not of a control — and it sat below a
   greeting and a 40pt first name, so the thing people open this screen to use
   was a third of the way down it. The greeting is gone (see the masthead) and
   the bar has moved into the space it left.
   Taller as well: 15px of padding on a 15px line is a 46px target, and this is
   the one control on Home everybody touches. */
[data-portal] .isearch {
  display: flex; align-items: center; gap: 12px;
  width: 100%;
  margin: 4px 0 0; padding: 17px 20px;
  min-height: 56px;
  background: var(--s-sunken);
  border: 1px solid var(--e-hair);
  border-radius: var(--radius-pill);
  text-align: left; font-family: inherit; cursor: pointer;
  transition: border-color var(--motion-control) var(--ease),
              background-color var(--motion-control) var(--ease);
}
[data-portal] .isearch:hover { border-color: var(--e-line); }
[data-portal] .isearch svg { width: 18px; height: 18px; color: var(--n300); flex: 0 0 auto; }
[data-portal] .isearch-ph { font-size: 15px; font-weight: 400; color: var(--n300); }

/* ═══ SEARCH ══════════════════════════════════════════════════════════════
   The field is the screen, so it gets the screen's full width and a gold
   glyph — the one place on this page where the accent marks "start here"
   rather than live or owed, because there is nothing else on the screen to
   mark. */
[data-portal] .srch {
  display: flex; align-items: center; gap: 11px;
  padding: 0 var(--sp-wrap-x); margin-bottom: 8px;
}
[data-portal] .srch > svg {
  width: 19px; height: 19px; flex: 0 0 auto; color: var(--gold);
}
[data-portal] .srch-in {
  flex: 1; min-width: 0; min-height: 52px; padding: 14px 0;
  background: none; border: 0; border-radius: var(--radius-sm);
  color: var(--text); font-family: inherit;
  /* The 16px floor. A search box below it zooms iOS on focus, and a zoomed
     page is where somebody stops searching. */
  font-size: var(--fs-input); font-weight: 400;
}
[data-portal] .srch-in:focus { outline: none; }
[data-portal] .srch-in::placeholder { color: var(--n200); }
[data-portal] .srch-x {
  width: 34px; height: 34px; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 0; color: var(--n300); cursor: pointer;
}
[data-portal] .srch-x svg { width: 15px; height: 15px; }
[data-portal] .srch-x[hidden] { display: none; }
/* A rule under the field, so results hang off it the way a list hangs off a
   masthead everywhere else. */
[data-portal] #srchResults { border-top: 1px solid var(--hair); }
[data-portal] #srchResults .row { border-bottom: 1px solid var(--hair); }

/* A category's services, and the browse grid on its own screen. */
[data-portal] .cats + * { margin-top: 0; }

/* ═══ THE BOOKING DETAIL ══════════════════════════════════════════════════ */

[data-portal] .detail-head { padding: 18px var(--sp-wrap-x) 16px; }

/* ── The status record ─────────────────────────────────────────────────────
   A NUMBERED, RULED LEDGER — not a rail of dots joined by a line.

   The dot rail is the single most generic component in mobile design: the
   same one ships in every delivery, courier and ride app on the phone, and
   on a screen whose whole job is to make EBUTECH look like a company you
   would trust with your house, "looks like everything else" is the failure.

   This reads as a record instead: an index column in tabular figures, a
   hairline between entries, timestamps right-aligned. Closer to a bill of
   lading than to a progress bar — which is honest, because a booking IS a
   record of what was agreed and what has happened since.

   WIDTHS ARE FIXED, not intrinsic. The index and time columns are hard
   widths so every row's label starts and ends on the same two verticals; a
   ledger whose columns drift is just a list. */
/* ── The Status header, which folds the ledger away ────────────────────────
   A whole row, tappable across its width, carrying three things: the label,
   the stage it is at, and which way it is about to go. The stage is on the
   header rather than only inside the ledger so that folding it hides the
   HISTORY and never the ANSWER — "Status · Work complete" is the whole
   question most people open this screen to ask.

   Square, flat, no fill: it is a heading you can press, not a button. */
/* THE ROW KEEPS THE 40px TAP FLOOR. Shortening it to 36 was the obvious way to
   take height out of this block and it is the wrong one: scripts/check-type-
   floors.js caught it, and the floor is there because a control people press
   on a phone has a minimum size whatever the layout wants.

   The height came out of the CARD instead, which is where most of it was — an
   18px band above and below a 40px row is nearly half the block. See
   `.card-status` below. */
[data-portal] .status-head {
  display: flex; align-items: center; gap: 12px;
  width: 100%; margin: 0; padding: 0 var(--sp-wrap-x);
  min-height: var(--tap);
  background: none; border: 0; border-radius: var(--radius-md);
  font-family: inherit; text-align: left; cursor: pointer;
}

/* The Status card, tightened. When the ledger is folded this card is one row,
   and it was carrying a full card's padding around it — a band of empty ground
   between the job header and the price, on the screen where the price is what
   the client came back for. Padding only; the row itself is untouched. */
[data-portal] .card-status { padding-top: 8px; padding-bottom: 8px; }
/* Open, the ledger needs its own air back underneath the header. */
[data-portal] .card-status.is-open { padding-bottom: 16px; }
[data-portal] .status-head .group-title { margin: 0; flex: 0 0 auto; }
/* Empty on purpose: it pushes the chevron to the far edge so the whole row is
   the target. The stage used to sit here and was removed — it is already the
   pill at the top of the same screen, and saying "Payment due" twice on one
   screen reads as two facts until you work out it is one. */
[data-portal] .status-spacer { flex: 1; min-width: 0; }
[data-portal] .status-chev {
  flex: 0 0 auto; display: inline-flex; color: var(--n300);
  transition: transform var(--motion-control) ease;
}
[data-portal] .status-chev svg { width: 16px; height: 16px; }
/* Down when the ledger is open, so the chevron points at what it controls. */
[data-portal] .status-chev.is-open { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) {
  [data-portal] .status-chev { transition: none; }
}

[data-portal] .tl { padding: 0 var(--sp-wrap-x); }
[data-portal] .tl-row {
  display: flex; align-items: flex-start; gap: 14px;
  padding: 13px 0; position: relative;
}
[data-portal] .tl-row + .tl-row { border-top: 1px solid var(--hair); }
/* The current entry: a faint gold ground between two gold rules. It marks
   without enclosing, and it is the only lit row. The ground bleeds past the
   text column so it reads as a band across the record, not as a card. */
[data-portal] .tl-row.is-now {
  background: var(--gold-soft);
  padding: 15px 12px; margin: 0 -12px;
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
}
[data-portal] .tl-row.is-now + .tl-row { border-top: 0; }
/* ── The spine ─────────────────────────────────────────────────────────────
   A NUMBER IS NOT PROGRESS. This column read "01 02 03 04", which tells
   somebody the position of a step in a list they are not counting; a client
   opening Status wants one thing, which is how far along their job is. So the
   column draws the answer: a rail with a mark per step, filled behind where
   the job has got to and hollow in front of it. The whole shape reads before a
   single word does.

   24px wide, exactly the column the numbers occupied, so nothing else on the
   row had to move. `align-self: stretch` lets the two stems fill whatever
   height the label needs — a two-line label keeps its rail unbroken. */
[data-portal] .tl-spine {
  width: 24px; flex: 0 0 auto; align-self: stretch;
  display: flex; flex-direction: column; align-items: center;
  /* BLEEDING THROUGH THE ROW'S PADDING. `align-self: stretch` fills the
     CONTENT box, so with `padding: 13px 0` on the row the rail stopped 13px
     short at each end and the whole column read as a row of short dashes
     rather than as one line. Negative margins of exactly the padding put the
     stems back edge to edge, so consecutive rows join. */
  margin-top: -13px; margin-bottom: -13px;
}
/* The lit row is padded differently, so its bleed has to match or the rail
   steps in and out at the one row everybody is looking at. */
[data-portal] .tl-row.is-now .tl-spine { margin-top: -15px; margin-bottom: -15px; }
[data-portal] .tl-stem { width: 2px; flex: 1; min-height: 6px; background: var(--hair); }
[data-portal] .tl-stem.is-on { background: var(--n200); }
[data-portal] .tl-stem.is-hidden { background: transparent; }
/* Three marks, separated by size and fill rather than by colour alone — the
   same rule the labels follow. */
[data-portal] .tl-mark {
  width: 9px; height: 9px; margin: 3px 0; flex: 0 0 auto;
  border: 1.5px solid var(--n100); background: transparent; border-radius: var(--radius-xs);
}
[data-portal] .tl-row.is-done .tl-mark { background: var(--n300); border-color: var(--n300); }
[data-portal] .tl-row.is-now .tl-mark {
  width: 13px; height: 13px; margin: 1px 0;
  background: var(--gold); border-color: var(--gold);
}
/* A finished job needs no special mark: `settled` already adds `is-done` to
   its last row, so it fills in the neutral ramp rather than in gold, which is
   exactly right — nothing on it is live and nothing is owed (rule 4). */
/* The loader in front of the ledger, given roughly the height the steps will
   occupy so the card does not jump when they arrive. */
[data-portal] .status-wait {
  display: flex; align-items: center; justify-content: center;
  padding: 34px 0;
}
[data-portal] .status-spin {
  width: 22px; height: 22px; border-radius: 50%;
  border: 2px solid var(--hair); border-top-color: var(--gold);
  animation: auth-spin .8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  [data-portal] .status-spin { animation-duration: 2.4s; }
}
[data-portal] .tl-body { flex: 1; min-width: 0; }
[data-portal] .tl-labelrow { display: flex; align-items: center; gap: 8px; }
/* Weight, not colour, separates the three states (rule 3): a future entry is
   600 and dim, one already passed is 600 and readable, the current one is
   800 and full strength. */
[data-portal] .tl-label { font-size: 14px; font-weight: 600; line-height: 18px; color: var(--n100); }
[data-portal] .tl-row.is-done .tl-label { color: var(--n300); }
[data-portal] .tl-row.is-now .tl-label {
  font-size: 15.5px; font-weight: 800; color: var(--text); letter-spacing: -.013em;
}
/* The line that earns this redesign. "Work completed" tells somebody where
   the job got to; it does not tell them what is now theirs to do. Only ever
   on the current row — every row carrying advice is a wall of text. */
[data-portal] .tl-note {
  margin-top: 5px; font-size: 12.5px; font-weight: 400; line-height: 1.36; color: var(--n400);
}
[data-portal] .tl-time {
  flex: 0 0 auto; font-size: 11.5px; line-height: 18px; color: var(--n200);
}
[data-portal] .tl-row.is-now .tl-time { color: var(--n400); }
/* The dot rail is gone. Any leftover markup must not draw half of it. */
[data-portal] .tl-dot,
[data-portal] .tl-line { display: none; }

/* ── The EBUPAY prompt ─────────────────────────────────────────────────────
   A ROW IN THE FEED between two hairlines, not a dialog. A modal over Home
   would be the loudest thing in the product used for the least urgent thing in
   it, and the first click of every visit would be closing it.

   The one that names a bill sits on the faint gold ground the rest of the app
   uses for what is owed. No card and no shadow: a ground between two edges. */
[data-portal] .hp {
  display: flex; align-items: center;
  padding-left: var(--sp-wrap-x); padding-right: 6px;
  border-top: 1px solid var(--hair); border-bottom: 1px solid var(--hair);
}
[data-portal] .hp.is-lit {
  background: var(--gold-soft);
  border-top-color: var(--gold-line); border-bottom-color: var(--gold-line);
}
[data-portal] .hp-main {
  flex: 1; min-width: 0;
  display: flex; align-items: center; gap: 13px;
  padding: 14px 0;
  background: none; border: 0; text-align: left; cursor: pointer; color: inherit;
  font: inherit;
}
[data-portal] .hp-ic {
  width: 36px; height: 36px; flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,.05); color: var(--n400);
}
[data-portal] .hp-ic svg { width: 18px; height: 18px; }
[data-portal] .hp.is-lit .hp-ic { background: var(--gold-halo); color: var(--gold); }
/* `min-width: 0` on the flex CHILD as well: without it the body refuses to
   shrink below its content and the whole row overflows on a 360px phone. */
[data-portal] .hp-body { flex: 1; min-width: 0; display: block; }
[data-portal] .hp-title {
  display: block; font-size: 14px; font-weight: 800; color: var(--text);
  letter-spacing: -.011em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
[data-portal] .hp-text {
  display: block; font-size: 12.5px; color: var(--n300); margin-top: 2px; line-height: 1.3;
}
[data-portal] .hp-cta {
  flex: 0 0 auto; font-size: 12.5px; font-weight: 800; color: var(--n400);
}
[data-portal] .hp.is-lit .hp-cta { color: var(--gold); }
/* Its own target, outside the one that navigates: a close inside a clickable
   row is a close that fires the row half the time. 34px is the smallest thing
   a thumb finds reliably. */
/* 40, NOT 34. The app's copy of this can sit at 34 because it carries a
   `hitSlop` that extends the touch area past the paint; there is no hitSlop on
   the web, so the element itself has to be the target. `check-type-floors.js`
   caught it at both phone widths, which is exactly what that floor is for. */
[data-portal] .hp-x {
  width: 40px; height: 40px; flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0; cursor: pointer; color: var(--n200);
}
[data-portal] .hp-x svg { width: 15px; height: 15px; }
[data-portal] .hp-x:hover { color: var(--text); }

/* ── Money ─────────────────────────────────────────────────────────────────
   `is-total` makes it the last line: heavier, larger, and the only one a
   client is actually asked to pay. */

/* ── The arrival code ──────────────────────────────────────────────────────
   The loudest thing on the screen when it is on it, so it gets the
   construction: a faint gold ground between two gold rules. */
[data-portal] .verify-box {
  padding: 20px var(--sp-wrap-x);
  background: var(--gold-soft);
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
  border-left: 0; border-right: 0; border-radius: var(--radius-lg); box-shadow: none;
  text-align: center;
}
[data-portal] .verify-code {
  font-size: 34px; font-weight: 800; letter-spacing: .16em;
  color: var(--gold-hi); font-variant-numeric: tabular-nums;
  background: none; -webkit-background-clip: border-box; -webkit-text-fill-color: currentColor;
}

/* ── The professional ──────────────────────────────────────────────────────*/

/* ═══ EBUCHAT ═════════════════════════════════════════════════════════════
   The same language as the app's flat variant: square, hairline-ruled, and
   the client's own messages are NOT gold. Alignment already says who spoke,
   so the two fills differ in weight rather than hue — half a screen of gold
   blocks would spend the accent on nothing, and the one piece of gold left is
   the send button, which is exactly where the eye should go. */

/* ── The inbox ──────────────────────────────────────────────────────────── */
[data-portal] .chat-row {
  gap: 14px; padding: 13px var(--sp-wrap-x);
  border-bottom: 1px solid var(--hair);
}
[data-portal] .chat-row:last-child { border-bottom: 1px solid var(--hair); }
[data-portal] .chat-row:hover { background: rgba(255, 255, 255, 0.04); }
[data-portal] .chat-row.is-open { background: rgba(255, 255, 255, 0.05); }
[data-portal] .chat-avatar,
[data-portal] .chat-avatar img {
  border-radius: var(--radius-sm); background: var(--block); color: var(--gold);
  font-weight: 800; border: 0;
}
/* Square, so the unread marker is a block in the corner rather than a
   crescent clipped by a circle. */
[data-portal] .chat-dot {
  border-radius: var(--radius-pill); width: 12px; height: 12px;
  background: var(--gold); border: 2px solid var(--bg);
}
[data-portal] .chat-name {
  font-size: 15px; font-weight: 600; letter-spacing: -.01em; color: var(--text);
}
[data-portal] .chat-name.is-unread { font-weight: 800; }
[data-portal] .chat-when {
  font-size: 11.5px; color: var(--n200); font-variant-numeric: tabular-nums;
}
[data-portal] .chat-preview { font-size: 13.5px; color: var(--n300); margin-top: 2px; }
[data-portal] .chat-preview.is-unread { color: var(--text); font-weight: 600; }
/* What the conversation is ABOUT, as an eyebrow — a row's category in this
   language is tracked-out uppercase, and NOT accented: rule 4 keeps gold for
   what is live and what is owed, and every row has a service. */
[data-portal] .chat-service,
[data-portal] .chat-closed {
  font-size: 10px; font-weight: 600; letter-spacing: .12em;
  text-transform: uppercase; color: var(--n200);
}
[data-portal] .chat-closed { font-weight: 800; }

/* The pane header is a duplicate now. On desktop the shared layer labels the
   left column with "EBUCHAT / Your conversations" — right for a screen pushed
   from inside Bookings, which is what this used to be. EBUCHAT is a TAB now
   and opens with a 52px masthead saying the same two things four lines above
   it, so the pane header is the same words twice on one screen. */
@media (min-width: 900px) {
  [data-portal] .chat-side-head { display: none; }
}

/* ── The conversation ───────────────────────────────────────────────────── */
[data-portal] .chat-head {
  padding: 10px var(--sp-wrap-x); gap: 12px;
  border-bottom: 1px solid var(--hair); background: var(--bg);
}
[data-portal] .chat-head-name { font-size: 16px; font-weight: 800; letter-spacing: -.01em; }
[data-portal] .chat-head-role { font-size: 12.5px; color: var(--n300); }
/* The header is a button: it fills the bar and its chevron says so. */
[data-portal] .chat-who {
  display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0;
  padding: 4px 0; background: none; border: 0;
  font-family: inherit; color: var(--text); text-align: left; cursor: pointer;
}
/* A plain forward chevron: this pushes a screen, it does not expand. */
[data-portal] .chat-who-chev {
  flex: 0 0 auto; color: var(--n200); display: inline-flex;
}
[data-portal] .chat-who-chev svg { width: 15px; height: 15px; }

/* ═══ THE PROFESSIONAL'S SCREEN ═══════════════════════════════════════════
   Reached from the chat header — the same gesture WhatsApp and Messenger use
   for contact info, and the same shape: the photo large at the top, the facts
   under it, the actions at the bottom.

   It shares the ACCOUNT MASTHEAD'S WASH (see .band). One construction for the
   two screens that are about a person — you, and them — rather than two
   separate exceptions to rule 2. scripts/check-swiss.js names it that way.

   THE PHOTO IS SQUARE. Rule 1 has exactly one exception and it is the
   client's OWN profile photo, not somebody else's. It is large because this
   is the screen you opened in order to look at somebody. */
[data-portal] .pro-hero {
  padding: 28px var(--sp-wrap-x) 26px;
  display: flex; flex-direction: column; align-items: center; text-align: center;
  /* The one gradient in the system, doing what a card would otherwise do:
     marking the top of the screen without drawing a box round it. */
  background: linear-gradient(170deg,
    rgba(242, 203, 87, 0.12) 0%,
    rgba(242, 203, 87, 0.03) 48%,
    rgba(242, 203, 87, 0) 100%);
}
[data-portal] .avatar-hero {
  width: 132px; height: 132px; font-size: 44px;
}
[data-portal] .pro-name {
  margin: 20px 0 0; font-size: 26px; font-weight: 800; letter-spacing: -.025em;
  line-height: 1.1; color: var(--text);
}
[data-portal] .pro-role { margin-top: 6px; font-size: 14px; color: var(--n300); }
[data-portal] .pro-hero .vchip { margin-top: 14px; }

[data-portal] .pro-facts { padding: 18px var(--sp-wrap-x); }
[data-portal] .pro-facts .stats { margin-top: 0; }
[data-portal] .pro-note { padding: 18px var(--sp-wrap-x) 8px; max-width: 52ch; }

[data-portal] .chat-body { padding: 12px var(--sp-wrap-x) 8px; }

/* A day heading. The rounded pill was a container around two words; here the
   tracking does the same job with nothing around it. */
[data-portal] .chat-day span {
  background: none; padding: 0; border-radius: var(--radius-pill);
  font-size: 10px; font-weight: 800; letter-spacing: .14em;
  text-transform: uppercase; color: var(--n200);
}

[data-portal] .msg {
  border-radius: var(--radius-md); padding: 11px 14px;
  background: rgba(255, 255, 255, 0.05); border: 0;
  border-left: 2px solid var(--rule);
}
/* NOT gold. See the note at the top of this section. */
[data-portal] .msg-mine {
  background: var(--block); border: 0;
  border-right: 2px solid var(--gold); border-left: 0;
}
[data-portal] .msg-body { font-size: 15px; line-height: 1.4; color: var(--text); }
[data-portal] .msg-mine .msg-body { color: var(--text); }
[data-portal] .msg-meta {
  font-size: 10.5px; color: var(--n200); font-variant-numeric: tabular-nums;
}
[data-portal] .msg-mine .msg-meta { color: var(--n200); opacity: 1; }
[data-portal] .msg-failed {
  background: rgba(255, 138, 128, 0.10); border-right-color: var(--danger);
}
[data-portal] .msg-retry { color: var(--danger); font-weight: 800; }

[data-portal] .chat-composer {
  padding: 8px var(--sp-wrap-x) calc(10px + env(safe-area-inset-bottom));
  border-top: 1px solid var(--hair); background: var(--bg);
}
[data-portal] .chat-input {
  border-radius: var(--radius-sm); padding: 12px 14px;
  background: rgba(255, 255, 255, 0.05); border: 1px solid var(--rule);
}
[data-portal] .chat-input:focus { border-color: var(--gold); }
/* The one piece of gold on the screen, which is the point of reserving it. */
[data-portal] .chat-send { border-radius: var(--radius-pill); }
[data-portal] .chat-closed-bar {
  border-top: 1px solid var(--hair); color: var(--n200); font-size: 12.5px;
}

/* ── Searching your own conversations ──────────────────────────────────────
   A bar at the top of the inbox, not a magnifying glass that expands into
   one: the list can be thirty long and the box has to be visible before
   somebody wonders whether there is one. Square, hairlined, 16px inside —
   below that iOS zooms the page on focus.

   IT SEARCHES THREADS THAT EXIST, never a directory of professionals. See the
   note in account-page.js: a directory would be an oracle for who works for
   EBUTECH, and a channel for arranging a job off the platform. */
[data-portal] .chat-search {
  display: flex; align-items: center; gap: 11px;
  padding: 11px var(--sp-wrap-x);
  border-bottom: 1px solid var(--hair);
}
[data-portal] .chat-search > svg {
  width: 17px; height: 17px; flex: 0 0 auto; color: var(--n200);
}
[data-portal] .chat-search-in {
  flex: 1; min-width: 0;
  background: none; border: 0; padding: 0;
  color: var(--text); font-family: inherit; font-size: var(--fs-input);
}
[data-portal] .chat-search-in:focus { outline: none; }
[data-portal] .chat-search-in::placeholder { color: var(--n200); }
/* The browser's own clear affordance, removed: it is a grey circle in a design
   with no circles, and it sits where our own square control goes. */
[data-portal] .chat-search-in::-webkit-search-cancel-button { display: none; }
[data-portal] .chat-search-x {
  flex: 0 0 auto; width: 30px; height: 30px;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 0; border-radius: var(--radius-sm);
  color: var(--n300); cursor: pointer;
}
[data-portal] .chat-search-x:hover { color: var(--text); background: rgba(255, 255, 255, .06); }
[data-portal] .chat-search-x svg { width: 15px; height: 15px; }

/* The countdown, above a composer that is still open. Quiet — it is a notice,
   not a warning, and a thread with four hours left should not look urgent. */
[data-portal] .chat-closing {
  padding: 10px var(--sp-wrap-x);
  border-top: 1px solid var(--hair);
  font-size: 12px; color: var(--n300); text-align: center;
}

/* ═══ THE ACCOUNT TAB ═════════════════════════════════════════════════════ */

/* THE ONE GRADIENT IN THE SYSTEM, and it earns its place by doing what a card
   would otherwise do: marking this block as the top of the section without
   drawing a box around it. Straight off the app's account masthead. */
[data-portal] .band,
[data-portal] .ident {
  position: relative; overflow: hidden;
  padding: 20px var(--sp-wrap-x) 22px;
  background: linear-gradient(160deg,
    rgba(242, 203, 87, 0.13) 0%,
    rgba(242, 203, 87, 0.03) 45%,
    rgba(242, 203, 87, 0) 100%);
  border: 0; border-radius: var(--radius-md); box-shadow: none;
}
/* THE PORTRAIT SITS BESIDE THE NAME, and this rule is what makes it. It was
   lost in a merge: `.band-id` and a `.band::after` that no longer has any
   content ended up sharing `.band-b`'s declaration block, so `.band-id` got
   `flex: 1` and never got `display: flex`. The result was the identity block
   drawn down the middle of the screen — a circle centred over a name — with
   two 2px gold slivers beside it, which are the `.avatar-wrap` ring collapsed
   to nothing because the inline span it is on wrapped around a block child.
   It was wrong on the customer account and the worker portal alike.

   A selector line ending in a comma before what should be its own block is
   invisible in review and this is the second time it has bitten this file. */
[data-portal] .band-id {
  display: flex; align-items: center; gap: 16px; min-width: 0;
}
[data-portal] .band-b { min-width: 0; flex: 1; }
/* The gold ring. A flex item in its own right, so it keeps its 76px and the
   ring is a ring rather than a sliver. */
[data-portal] .band .avatar-wrap,
[data-portal] .ident .avatar-wrap {
  display: inline-flex; flex: 0 0 auto;
}
[data-portal] .band-n {
  font-size: 26px; font-weight: 800; letter-spacing: -.027em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
[data-portal] .band-e {
  margin-top: 5px; font-size: 13.5px; color: var(--n300);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
[data-portal] .vchip { margin-top: 8px; }

/* The Edit profile control, top-right of the masthead. */

/* ═══ WHAT THE OLD LANGUAGE LEFT BEHIND ═══════════════════════════════════
   The map, the sheet and the horizontal card rail are gone from the markup
   (see viewHome in account-page.js). These rules exist so a stale cached
   script cannot paint half of the deleted design over the new one — a blank
   screen is recoverable, a hybrid of two design systems is not. */
[data-portal] .mapg,
[data-portal] .mapchrome,
[data-portal] .grab,
[data-portal] .deck-dots,
[data-portal] .jc-rail-x { display: none; }
[data-portal] .sheet {
  position: static; background: none; border: 0; border-radius: var(--radius-lg);
  box-shadow: none; padding: 0; transform: none;
}
[data-portal] .sheet::before { content: none; }

/* ── EXCEPT THE MODAL ONE, WHICH IS A DIFFERENT THING WITH THE SAME NAME ───
 *
 * THIS IS THE BUG BEHIND "the add money popup overlaps the balance", and
 * behind the PIN dialog being see-through before it became its own screen.
 *
 * `.sheet` meant two things. One was the old Home idle PANEL, deleted with the
 * rest of that design and neutralised by the rule above so no stale script
 * could paint half of it back. The other is the MODAL that `sheet()` in
 * account-page.js still renders — same class name, completely different job —
 * and the neutraliser stripped its ground, its padding and its positioning
 * along with the panel's. What was left floated over the wallet with no fill
 * at all: the title, the account number and the buttons all sitting directly
 * on top of the ledger rows, both legible and neither readable.
 *
 * Scoped through `.sheet-backdrop`, which only the modal ever has, so the
 * neutraliser keeps doing its job and this wins on specificity without either
 * rule needing to know about the other.
 *
 * Square, flat, solid: a bottom sheet on a phone, centred on a laptop. */
[data-portal] .sheet-backdrop {
  background: rgba(0, 0, 0, 0.72);
  padding: 0;
}
[data-portal] .sheet-backdrop .sheet {
  position: relative;
  width: 100%; max-width: 520px;
  background: var(--bg);
  border: 0; border-top: 1px solid var(--rule);
  border-radius: var(--radius-lg);
  padding: 22px var(--sp-wrap-x) calc(24px + env(safe-area-inset-bottom));
  max-height: 88dvh; overflow-y: auto;
  box-shadow: none;
}
@media (min-width: 760px) {
  [data-portal] .sheet-backdrop { align-items: center; }
  [data-portal] .sheet-backdrop .sheet {
    border: 1px solid var(--rule);
    max-height: 84dvh;
    padding-bottom: 24px;
  }
}

/* ── Three things that hardcoded a radius ─────────────────────────────────
   The shared layer moves in one edit because every radius in it reads a
   `--radius-*` token, and this scope defines them. These three did not read a
   token — they wrote `border-radius: 50%` / `10px` straight into the rule — so
   they sat at their own value while everything around them followed the
   tokens, and each was the one wrong-shaped thing on its screen. Invisible in
   review, obvious in a computed-style sweep: scripts/check-swiss.js is what
   found them. (The tokens were all `0` when this was written and are now the
   six-step scale; that is exactly the edit these three would have missed.)

   The duty knob is the second switch on the site (`.switch-thumb` is the
   other) and it belongs to the "I'm at this address now" toggle on the
   booking form. It keeps its LIGHT colour — that is load-bearing, not
   decorative: on a near-black track a dark knob leaves the switch with no
   visible off state at all — and loses the drop shadow, which rule 2 does not
   allow and which a 1px hairline replaces. */
[data-portal] .duty-knob {
  border-radius: var(--radius-pill); box-shadow: none;
  background: var(--n200);
}
[data-portal] .duty.is-on .duty-knob { background: var(--on-gold); }
[data-portal] .duty-switch { border-radius: var(--radius-md); }
[data-portal] .chat-badge { border-radius: var(--radius-pill); }
[data-portal] .notif-icon { border-radius: var(--radius-sm); }

/* ═══ THE AUTH PAGES ══════════════════════════════════════════════════════
   /login, /create-account, /forgot-password, /verify-email.

   They are the doorway to everything above, and until now they were a white
   rounded card on the marketing site's light grey — so the product changed
   identity at the exact moment somebody committed to it. Same move the app
   made when its auth screens were the last of the old rounded system left.

   NO CARD, which is rule 2 and also the right answer here: the form IS the
   page, so a box around it is a box around the only thing on the screen. What
   replaces it is a section rule under the heading and air. */

[data-portal] .auth-body {
  min-height: 100vh; min-height: 100dvh;
  background: var(--bg); color: var(--text);
}
[data-portal] .auth-bar {
  display: flex; align-items: center;
  padding: calc(18px + env(safe-area-inset-top)) var(--sp-wrap-x) 18px;
}
[data-portal] .auth-brand {
  display: inline-flex; align-items: center; gap: 9px;
  min-height: var(--tap); text-decoration: none;
}
[data-portal] .auth-brand img { width: 24px; height: 24px; object-fit: contain; }
[data-portal] .auth-brand-word {
  font-size: var(--fs-caps); font-weight: 800;
  letter-spacing: .16em; color: var(--gold); text-transform: uppercase;
}

[data-portal] .auth-section {
  background: none; min-height: 0;
  display: block; padding: 0 0 64px;
}
[data-portal] .auth-wrap {
  width: 100%; max-width: 440px; margin: 0 auto;
  padding: 0 var(--sp-wrap-x);
}
[data-portal] .auth-card {
  background: none; border: 0; border-radius: var(--radius-lg); box-shadow: none;
  padding: 0; position: relative;
}

[data-portal] .auth-head {
  margin-bottom: 26px; padding-bottom: 22px;
  border-bottom: 2px solid var(--rule);
}
[data-portal] .auth-eyebrow {
  display: block; margin-bottom: 12px;
  font-size: var(--fs-caps); font-weight: 600;
  letter-spacing: .11em; text-transform: uppercase; color: var(--gold);
}
[data-portal] .auth-title {
  font-size: 34px; font-weight: 800; line-height: 1.05;
  letter-spacing: -.025em; color: var(--text); margin: 0;
}
[data-portal] .auth-lead {
  margin-top: 10px; font-size: 13.5px; font-weight: 400;
  line-height: 1.5; color: var(--n300);
}
/* Back, on the two-step reset. A square outline, not a bare arrow glyph
   floating above a heading. */
[data-portal] .auth-back {
  display: inline-flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; margin-bottom: 16px;
  border: 1px solid var(--rule); color: var(--text);
  text-decoration: none; font-size: 17px;
}
[data-portal] .auth-back:hover { background: rgba(255, 255, 255, 0.05); }

/* An error and a confirmation, both as a ground between two rules — the
   system's one construction for "read this now". Red for a refusal, gold for
   a code that is on its way. */
[data-portal] .auth-alert,
[data-portal] .auth-note {
  margin-bottom: 20px; padding: 13px 14px;
  border-radius: var(--radius-md); font-size: 13px; line-height: 1.45;
}
[data-portal] .auth-alert {
  background: var(--danger-bg); color: var(--danger);
  border-top: 2px solid var(--danger); border-bottom: 2px solid var(--danger);
}
[data-portal] .auth-note {
  background: var(--gold-soft); color: var(--gold-hi);
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
}

/* HIDDEN MEANS HIDDEN. `[hidden]` is an attribute selector and loses to any
   class rule that sets `display`, so `.auth-form { display: flex }` below
   painted all three sign-in forms — sign in, request a link, set a PIN — down
   one page at once. Every element in this layer that a script toggles with
   `hidden` is at risk of the same thing, so it is stated once, here, for all
   of them rather than patched per component. */
[data-portal] [hidden] { display: none !important; }

[data-portal] .auth-form { display: flex; flex-direction: column; gap: 18px; }
[data-portal] .auth-field { display: flex; flex-direction: column; gap: 8px; }
/* The label sits ABOVE the box, tracked out, never floating inside it — a
   placeholder doubling as a label vanishes the moment somebody types, which
   is precisely when they most want to check what they are filling in. */
[data-portal] .auth-label {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
  font-size: var(--fs-caps-sm); font-weight: 600;
  letter-spacing: .12em; text-transform: uppercase; color: var(--n300);
}
[data-portal] .auth-label-link {
  font-size: 10px; font-weight: 800; letter-spacing: .08em;
  color: var(--gold); text-decoration: none; text-transform: uppercase;
}
[data-portal] .auth-label-link:hover { text-decoration: underline; }
[data-portal] .auth-optional { font-weight: 600; color: var(--n200); }

[data-portal] .auth-input {
  width: 100%; min-height: 52px; padding: 14px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--rule); border-radius: var(--radius-sm); box-shadow: none;
  color: var(--text); font-family: inherit;
  /* 16px is the floor: below it iOS zooms the page on focus, and a zoomed
     sign-in form is where people give up. */
  font-size: var(--fs-input); font-weight: 400;
}
[data-portal] .auth-input:focus {
  outline: none; box-shadow: none;
  border-color: var(--gold); background: rgba(255, 255, 255, 0.07);
}
[data-portal] .auth-input::placeholder { color: var(--n200); }
[data-portal] .auth-hint { font-size: 11.5px; color: var(--n200); }

[data-portal] .auth-pw { position: relative; display: block; }
[data-portal] .auth-pw .auth-input { padding-right: 74px; }
[data-portal] .auth-pw-toggle {
  position: absolute; right: 1px; top: 1px; bottom: 1px;
  padding: 0 16px; border: 0; border-left: 1px solid var(--hair);
  background: none; color: var(--n400); cursor: pointer;
  font-family: inherit; font-size: 10px; font-weight: 800;
  letter-spacing: .1em; text-transform: uppercase;
}
[data-portal] .auth-pw-toggle:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); }

/* The submit button carries .btn from the shared layer, which this file has
   already made a flat gold block. All that is left is the height. */
[data-portal] .auth-submit { min-height: 52px; margin-top: 4px; }
[data-portal] .auth-submit:disabled { opacity: .45; box-shadow: none; transform: none; }

[data-portal] .auth-or {
  display: flex; align-items: center; margin: 26px 0;
  font-size: 10px; font-weight: 800; letter-spacing: .14em;
  text-transform: uppercase; color: var(--n200);
}
[data-portal] .auth-or::before,
[data-portal] .auth-or::after {
  content: ''; flex: 1; height: 1px; background: var(--hair);
}
[data-portal] .auth-or span { padding: 0 14px; }

/* Google's mark is theirs and their brand guidelines fix its colours, so the
   button stays a light one — that is not a break in the system, it is the one
   element on the page we are not allowed to restyle. Squared, because the
   corner radius IS ours. */
[data-portal] .auth-google {
  display: flex; align-items: center; justify-content: center; gap: 11px;
  width: 100%; min-height: 52px; padding: 14px;
  background: #FFFFFF; color: #1F1F1F;
  border: 0; border-radius: var(--radius-md); box-shadow: none;
  font-family: inherit; font-size: 15px; font-weight: 700; cursor: pointer;
}
[data-portal] .auth-google:hover { border-color: transparent; box-shadow: none; opacity: .92; }

[data-portal] .auth-linkbtn {
  align-self: flex-start; padding: 8px 0;
  background: none; border: 0; cursor: pointer;
  font-family: inherit; font-size: 12.5px; font-weight: 700;
  color: var(--gold); text-decoration: underline;
}
[data-portal] .auth-switch,
[data-portal] .auth-fine {
  margin-top: 26px; font-size: 13px; line-height: 1.5; color: var(--n300);
}
[data-portal] .auth-fine { margin-top: 18px; font-size: 11.5px; color: var(--n200); }
[data-portal] .auth-switch a,
[data-portal] .auth-fine a { color: var(--gold); font-weight: 700; }

/* Shown while /auth/me resolves, so there is no flash of an empty card before
   we know whether to redirect. */
[data-portal] .auth-loading {
  display: flex; flex-direction: column; align-items: flex-start; gap: 14px;
  color: var(--n300); font-size: 13px;
}
[data-portal] .auth-spinner {
  width: 22px; height: 22px; border-radius: 50%;
  border: 2px solid var(--hair); border-top-color: var(--gold);
  animation: auth-spin .8s linear infinite;
}
@keyframes auth-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  [data-portal] .auth-spinner { animation-duration: 2.4s; }
}

@media (min-width: 600px) {
  [data-portal] .auth-wrap { padding-top: 24px; }
  [data-portal] .auth-title { font-size: 40px; }
}

/* ═══ RESPONSIVE ══════════════════════════════════════════════════════════
   Three tiers, and each one is a different SHAPE rather than the same shape
   at a different width:

     phone    < 600   one column, bottom tab bar, full-bleed rules
     tablet   >= 600  the browse grid opens up and the measure grows; still
                      a bottom bar, because a tablet held in portrait is a
                      big phone and a rail down its left edge is a waste of
                      the hand holding it
     desktop  >= 900  the tab bar becomes a persistent left rail

   900 is not a free choice: portal-chrome.js reads the same number
   (PHONE_MAX) to decide whether the bar may auto-hide on scroll, and
   scripts/check-tab-autohide.js asserts the two agree. Change one, change
   all three. */

@media (min-width: 600px) {
  :root[data-portal] {
    --wrap: 660px;
    --sp-wrap-x: 24px;
    --fs-display: 46px;
  }
  /* Three across once there is room; the tile stops being a wide, empty bar.
     The row/column rules move with the column count — see the note by .cats.
     Order matters and they are all the same specificity: put the left rule
     back on every odd tile (the two-column rule above took it off) before
     taking it off the first of each three. */
  [data-portal] .cats { grid-template-columns: repeat(3, 1fr); }
  [data-portal] .cat:nth-child(-n+3) { border-top: 0; }
  [data-portal] .cat:nth-child(2n+1) { border-left: 1px solid var(--hair); }
  [data-portal] .cat:nth-child(3n+1) { border-left: 0; }
  [data-portal] .empty { padding-top: 72px; }
}

@media (min-width: 900px) {
  :root[data-portal] {
    --wrap: 720px;
    --fs-display: 52px;
  }

  /* RESTYLE, DO NOT RE-LAY-OUT. The shared layer above already turns the tab
     bar into a rail the right way: `.app` becomes a flex ROW, `.tabs` becomes
     a sticky flex sibling at `flex: 0 0 232px`, and `.shell` takes the rest
     with `flex: 1`. A first cut of this block rebuilt all of that — fixed
     positioning on the rail plus `margin-left: 232px` on the shell — and the
     two stacked: the rail took its 232px out of the flex row AND the shell
     shifted another 232px, so the reading column sat 232px right of centre
     with a matching gap of dead ground beside it.

     It also replaced the rail's `padding-top: 84px`, which is what clears the
     FIXED top bar above it, so the collapse button climbed underneath the bar
     and painted the wordmark out. Everything below is colour, weight and
     radius; not one property here moves a box. */
  [data-portal] .tabs {
    padding: 84px 12px 18px;
    gap: 2px;
    background: var(--bg);
    border-right: 2px solid var(--rule);
  }
  [data-portal] .tab {
    gap: 14px; padding: 11px 14px; border-radius: var(--radius-md);
    font-size: 13px; font-weight: 700; letter-spacing: .02em; text-transform: none;
    min-height: 46px;
  }
  [data-portal] .tab:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); }
  /* Rule 4 again: the tab you are on is neither live nor owed. Weight and a
     lit edge say "here"; the shared layer's gold fill and gold ink would
     spend the accent on navigation. */
  [data-portal] .tab[aria-selected="true"] {
    background: rgba(255, 255, 255, 0.05); color: var(--text);
  }
  /* The marker moves to the leading edge, where a vertical list reads it. */
  [data-portal] .tab[aria-selected="true"]::before {
    top: 0; bottom: 0; left: 0; right: auto; width: 2px; height: auto;
  }
  [data-portal] .rail-toggle {
    border-radius: var(--radius-sm); border: 1px solid var(--rule); color: var(--n300);
  }
  [data-portal] .rail-toggle:hover {
    background: rgba(255, 255, 255, 0.05); color: var(--text);
  }
  /* The rail is navigation, and the bottom of the viewport is not reserved
     for a bar that is no longer there. */
  [data-portal] .wrap { padding-bottom: 64px; }

  /* Collapsed. The shared layer clips the label to a 1px box rather than
     hiding it, so the tab keeps its accessible name — do not "simplify" that
     to display:none. */
  :root[data-portal][data-rail="mini"] .tab { justify-content: center; padding: 11px 0; }
}

/* ═══ THE WORKER PORTAL ═══════════════════════════════════════════════════
   Everything above serves both portals. What follows is only the vocabulary
   the WORKER screens use and the customer account does not — the duty switch,
   the job blocks, the earnings hero, the credential, the payout account, the
   scanner.

   IT IS THE SAME DESIGN, NOT A SECOND ONE. Every rule here is the four rules
   applied to a shape the account never needed, and where the worker markup
   already carries a class the account uses (.card, .btn, .row, .group, .stat,
   .tag, .field, .empty) it is deliberately left alone so the two cannot drift.

   THE PORTAL DOES NOT HAVE A JOB DETAIL SCREEN, and that is not an oversight:
   a worker on site opens ONE screen and works down it, and a list that hides
   the button behind a tap is a tap taken in somebody's stairwell. So a job is
   a BLOCK — the facts, then the one thing to do next — separated from the
   next by a rule rather than boxed in a card. */

/* `.screen-title` was here, and it is gone with the last two screens that
   used it. Earnings and EBUCHAT wore a bare <h1> while Home and Jobs wore a
   masthead, which is two answers to "what does the top of a screen look like".
   Every worker screen wears `.wmast` now: the portrait, the title, the meta
   line. */
/* `.screen-title + .rule`, `.screen-title + .sec`, `.screen-sub` and
   `.display` were written here and then designed out — Jobs, Earnings and
   Account all took the account's `.mast` instead. They are deleted rather
   than kept "in case": scripts/check-dead-css.js found them styling nothing,
   and CSS that matches nothing is a claim about markup nobody can check. */

/* "3 live now", inline in the masthead's meta line. The pulsing dot marks ONE
   thing — that something is moving right now — so nothing is live, no dot. */
[data-portal] .mast-live {
  display: inline-flex; align-items: center; gap: 7px;
  color: var(--gold-hi); font-weight: 700;
}

/* ── Buttons the account never uses ────────────────────────────────────────
   RULE 4, AND IT MATTERED HERE MORE THAN ANYWHERE. `.btn-ghost` and
   `.btn-primary` had no rule in this layer, so they fell through to the plain
   `.btn` — solid gold. On an unaccepted job that meant "Accept this job" and
   "Can't take this job" rendered as two identical gold blocks, which is not a
   styling slip: it is the screen giving equal weight to taking work and
   refusing it, at the moment a client is waiting. */
[data-portal] .btn-primary { background: var(--gold); color: var(--on-gold); border: 0; }
/* The quietest control this system has, for the one action on a job block
   that should be possible but never inviting. AUTO WIDTH AND LEFT ALIGNED: as
   a full-width centred block it read as a heading — a grey sentence across the
   screen under the gold button, which is not what a button looks like. */
[data-portal] .btn-ghost {
  width: auto; justify-content: flex-start;
  padding: 10px 0; min-height: 40px;
  background: none; color: var(--n300);
  border: 0; box-shadow: none;
  font-size: 13px; font-weight: 700;
  text-decoration: underline; text-underline-offset: 3px;
}
[data-portal] .btn-ghost:hover { background: none; color: var(--text); opacity: 1; }
[data-portal] .btn-block { width: 100%; }
[data-portal] .btn-auto { width: auto; }

/* ── A BUTTON THAT IS NOT INSIDE ANYTHING ─────────────────────────────────
   Every block on these screens gives itself the screen gutter internally
   (`.card` is `padding: 18px var(--sp-wrap-x)`), so a `.btn` inside one is
   inset by 18px and its curve has a real corner to sit on. A button placed
   as a SIBLING of those blocks — "Book Chidi again" on a finished job, the
   payout screen's action — had no such parent and ran the full width of the
   phone, where a 14px radius cuts two notches out of the screen's own edge.

   The gutter is the fix rather than squaring the button: the app puts its
   buttons inside a padded section and they are rounded, and a control that
   is flush to both edges on one surface and inset on the other is the drift
   this whole scale exists to prevent. */
[data-portal] #view > .btn {
  width: calc(100% - var(--sp-wrap-x) * 2);
  margin-left: var(--sp-wrap-x);
  margin-right: var(--sp-wrap-x);
}
/* Buttons stacked in a job block breathe; two gold blocks touching read as
   one control with a seam down it. */
[data-portal] .job-actions .btn + .btn { margin-top: 10px; }

/* ── Am I free today? ──────────────────────────────────────────────────────
   A ROW, NOT A CARD, and NOT GOLD WHEN ON. "Available" is the normal state of
   a working day; an accent lit most of the time stops meaning anything (rule
   4), and the switch already says which way it is set. The bordered gold box
   this replaces was the loudest thing on the worker's home screen, above the
   job somebody was actually waiting on. */
[data-portal] .duty {
  display: flex; align-items: center; gap: 16px; width: 100%;
  padding: 16px var(--sp-wrap-x);
  background: none; border: 0; border-radius: var(--radius-md);
  text-align: left; font-family: inherit; color: var(--text); cursor: pointer;
}
[data-portal] .duty.is-on { background: none; border-color: transparent; }
[data-portal] .duty:hover { background: rgba(255, 255, 255, 0.04); }
[data-portal] .duty:disabled { opacity: .55; cursor: default; }
/* A square, not a dot. It is a state lamp, and everything here is square. */
[data-portal] .duty-dot {
  width: 9px; height: 9px; flex: 0 0 auto;
  border-radius: var(--radius-pill); background: var(--n100);
}
/* No glow ring. The shared layer puts a 4px `box-shadow` halo behind the lit
   dot, which is rule 2 broken by something too small to notice by eye — found
   by scripts/check-swiss.js, which is exactly what it is for. */
[data-portal] .duty-dot { box-shadow: none; }
[data-portal] .duty.is-on .duty-dot { background: var(--success); box-shadow: none; }
[data-portal] .duty-label { font-size: 15px; font-weight: 800; letter-spacing: -.01em; }
[data-portal] .duty-hint {
  display: block; margin-top: 3px;
  font-size: 12.5px; font-weight: 400; color: var(--n300); line-height: 1.35;
  white-space: normal;
}
[data-portal] .duty-switch {
  width: 46px; height: 26px; padding: 2px; flex: 0 0 auto;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--rule); border-radius: var(--radius-md);
}
[data-portal] .duty.is-on .duty-switch { background: var(--gold); border-color: var(--gold); }
[data-portal] .duty-knob { width: 20px; height: 20px; }

/* ── Figures ───────────────────────────────────────────────────────────────
   The worker's tiles are the account's stat tiles with a different name, so
   they are given the same rule rather than a second look. NO ICON: a gold
   glyph above every figure is four accents on one screen, and it says nothing
   the label underneath does not. */
[data-portal] .tiles { display: flex; gap: 10px; padding: 0 var(--sp-wrap-x); }
[data-portal] .tiles + .tiles { margin-top: 10px; }
[data-portal] .tile {
  flex: 1 1 0; min-width: 0; padding: 13px 14px;
  display: block; text-align: left;
  background: none; border: 1px solid var(--hair); border-radius: var(--radius-md);
}
[data-portal] .tile-icon { display: none; }
[data-portal] .tile-value {
  display: block;
  font-size: 20px; font-weight: 800; letter-spacing: -.02em; color: var(--text);
  font-variant-numeric: tabular-nums;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
[data-portal] .tile-label {
  display: block; margin-top: 5px;
  font-size: 11.5px; font-weight: 600; color: var(--n300);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* ── One job ───────────────────────────────────────────────────────────────
   A block on the ground, ruled off from the next. The service names it, the
   facts sit under it in a two-column record, the actions close it. */
[data-portal] .job-head {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
}
[data-portal] .job-service { font-size: 17px; font-weight: 800; letter-spacing: -.015em; }
[data-portal] .job-meta { margin-top: 3px; font-size: 12.5px; color: var(--n300); }
[data-portal] .job-rows { margin-top: 14px; }
/* A RECORD, not a form: a fixed label column so every value starts on the
   same vertical, hairlined so a long "Details" cannot be mistaken for the
   row under it. */
[data-portal] .job-row {
  display: flex; gap: 14px; align-items: flex-start;
  padding: 9px 0; border-top: 1px solid var(--hair);
}
[data-portal] .job-row:first-child { border-top: 0; }
[data-portal] .job-row .lbl {
  flex: 0 0 88px;
  font-size: var(--fs-caps-sm); font-weight: 600; letter-spacing: .1em;
  text-transform: uppercase; color: var(--n200); line-height: 1.6;
}
[data-portal] .job-row .val { flex: 1; min-width: 0; font-size: 13.5px; line-height: 1.45; }
[data-portal] .job-row .val a { color: var(--gold); text-decoration: none; font-weight: 700; }
[data-portal] .job-row .val a:hover { text-decoration: underline; }
[data-portal] .job-actions { margin-top: 16px; }
[data-portal] .job-msg:empty { display: none; }
[data-portal] .job-msg {
  margin-top: 12px; padding: 12px 14px;
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
  background: var(--gold-soft);
  font-size: 13px; line-height: 1.45; color: var(--gold-hi);
}

/* A hint that reports something already true reads as reassurance, not as an
   instruction — so it is quiet and grey, never green-on-black. */
[data-portal] .hint-ok { color: var(--n400); font-weight: 600; }
[data-portal] .hint-error { color: var(--danger); }
[data-portal] .inline-form { display: flex; gap: 10px; }
[data-portal] .inline-form .field { flex: 1; min-width: 0; }
[data-portal] .inline-form .btn { width: auto; flex: 0 0 auto; padding-inline: 20px; }

/* ── A notice ──────────────────────────────────────────────────────────────
   The one construction for "read this now" — a faint ground between two
   rules. It marks without enclosing, and cannot be scrolled past. */
[data-portal] .notice {
  display: flex; gap: 13px; align-items: flex-start;
  margin: 16px 0; padding: 14px var(--sp-wrap-x);
  background: var(--gold-soft);
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
  border-radius: var(--radius-md);
}
[data-portal] .notice-icon { flex: 0 0 auto; color: var(--gold); }
[data-portal] .notice-icon svg { width: 19px; height: 19px; }
[data-portal] .notice-text { flex: 1; font-size: 13px; line-height: 1.45; color: var(--n400); }

/* ── Earnings ──────────────────────────────────────────────────────────────
   ONE gold figure on the screen — what is owed. Everything else here is a
   count, and a count is not money. */
[data-portal] .earn-hero {
  padding: 20px var(--sp-wrap-x);
  background: var(--gold-soft);
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
  /* The shared layer rounds this; the portal does not, because it runs edge
     to edge. Explicit rather than inherited: that is how it kept its curve. */
  border-radius: 0;
}
[data-portal] .earn-amount {
  display: block; margin-top: 12px;
  font-size: 46px; font-weight: 800; letter-spacing: -.04em; line-height: 1;
  color: var(--gold); font-variant-numeric: tabular-nums;
}
[data-portal] .earn-sub { display: block; margin-top: 8px; font-size: 12.5px; color: var(--n300); line-height: 1.45; }
[data-portal] .earn-processing {
  display: flex; align-items: center; gap: 11px; flex-wrap: wrap;
  margin-top: 16px; padding-top: 14px;
  border-top: 1px solid rgba(242, 203, 87, .25);
  font-size: 12.5px; color: var(--n400);
}

/* The payout record. A numbered ledger, the same construction as the booking
   timeline: an index column, a fixed date column, figures that line up — which
   is also what makes it checkable against a bank statement. */
[data-portal] .payout-row {
  display: flex; align-items: center; gap: 14px;
  padding: 14px var(--sp-wrap-x);
  border-top: 1px solid var(--hair);
}
[data-portal] .payout-amt {
  font-size: 15px; font-weight: 800; letter-spacing: -.01em;
  color: var(--gold-hi); font-variant-numeric: tabular-nums;
}
/* Settled. Still on the record, no longer owed — so it loses the accent. */
[data-portal] .payout-row.is-done .payout-amt { color: var(--text); }
[data-portal] .payout-figure {
  display: block; margin-top: 2px;
  font-size: 20px; font-weight: 800; letter-spacing: -.02em;
  color: var(--gold-hi); font-variant-numeric: tabular-nums;
}

/* ── The credential ────────────────────────────────────────────────────────
   Read by SOMEBODY ELSE, at arm's length, in a doorway, possibly at night —
   the only screen in either portal whose audience is not the person holding
   the device. So the code is the largest thing on it and nothing competes. */
/* SQUARE, like the earnings hero above it and the app's equivalents: this is
   a faint gold ground between two gold rules running edge to edge, not a card.
   A radius on it would curve two corners that sit against the screen's own. */
[data-portal] .idcard {
  padding: 22px var(--sp-wrap-x);
  background: var(--gold-soft);
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
  border-radius: 0; text-align: left;
}
[data-portal] .idcard-logo { display: none; }
[data-portal] .idcard-word {
  display: block;
  font-size: var(--fs-caps); font-weight: 800; letter-spacing: .16em;
  text-transform: uppercase; color: var(--gold);
}
[data-portal] .idcard-id { display: flex; gap: 16px; margin-top: 18px; }
[data-portal] .idcard-b { min-width: 0; flex: 1; }
/* SQUARE, and this is the exception to rule 1's exception. Everywhere else in
   EBUTECH a face is a circle; on a credential the photograph is evidence, not
   a portrait, and every country that issues one crops it square. */
[data-portal] .idcard-photo {
  width: 96px; height: 96px; flex: 0 0 auto;
  border-radius: var(--radius-sm); background: var(--block);
  font-size: 32px; font-weight: 800; color: var(--gold);
}
[data-portal] .idcard-photo img { border-radius: var(--radius-sm); }
[data-portal] .idcard-name {
  display: block; margin-top: 7px;
  font-size: 22px; font-weight: 800; letter-spacing: -.02em; line-height: 1.1;
}
[data-portal] .idcard-role { display: block; margin-top: 5px; font-size: 13px; color: var(--n400); }
[data-portal] .idcard-num {
  margin-top: 6px; font-size: 12px; letter-spacing: .06em; color: var(--n200);
  font-variant-numeric: tabular-nums;
}
[data-portal] .idcard-code-label {
  display: block; margin-top: 20px; padding-top: 16px;
  border-top: 1px solid rgba(242, 203, 87, .25);
  font-size: var(--fs-caps-sm); font-weight: 600; letter-spacing: .12em;
  text-transform: uppercase; color: var(--n300);
}
/* THE FIGURE THIS SCREEN EXISTS FOR, sized to the widest it can be without
   leaving the screen. A verification code is not always six digits —
   "EBU-VC-4471" is eleven characters — so it is set with clamp() against the
   viewport rather than at a fixed 42px that overflowed a 360px phone. */
[data-portal] .idcard-code {
  display: block; margin-top: 8px;
  font-size: clamp(26px, 9vw, 42px);
  font-weight: 800; letter-spacing: .08em; line-height: 1.15;
  color: var(--gold); font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}
[data-portal] .idcard-hint {
  display: block; margin: 0; padding: 18px var(--sp-wrap-x) 0;
  font-size: 13px; line-height: 1.5; color: var(--n300); max-width: 46ch;
}

/* ── The payout account ────────────────────────────────────────────────────
   Where the money lands. Confirming a name against a bank's own record is the
   one step that stops a worker being paid into somebody else's account, so it
   is set as a statement rather than as a hint. */
[data-portal] .py-intro { display: flex; gap: 14px; align-items: flex-start; padding: 18px var(--sp-wrap-x); }
[data-portal] .py-intro-icon {
  width: 42px; height: 42px; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--gold-soft); color: var(--gold);
}
[data-portal] .py-intro-icon svg { width: 19px; height: 19px; }
[data-portal] .py-intro-title { font-size: 15px; font-weight: 800; letter-spacing: -.01em; }
[data-portal] .py-intro-text { margin-top: 4px; font-size: 12.5px; color: var(--n300); line-height: 1.45; }
[data-portal] .py-card {
  padding: 16px var(--sp-wrap-x);
  background: none; border: 0; border-top: 1px solid var(--hair); border-radius: 0;
}
[data-portal] .py-card-name { font-size: 16px; font-weight: 800; letter-spacing: -.015em; }
[data-portal] .py-card-bank,
[data-portal] .py-card-acct { margin-top: 4px; font-size: 13px; color: var(--n300); font-variant-numeric: tabular-nums; }
[data-portal] .py-card-status {
  display: inline-flex; margin-top: 10px; padding: 3px 10px;
  background: rgba(237, 237, 240, .07); border-radius: var(--radius-md);
  font-size: 11px; font-weight: 600; color: var(--n500);
}
[data-portal] .py-confirm {
  margin-top: 14px; padding: 14px;
  background: var(--gold-soft);
  border-top: 2px solid var(--gold); border-bottom: 2px solid var(--gold);
  border-radius: var(--radius-md);
}
[data-portal] .py-confirm-label {
  font-size: var(--fs-caps-sm); font-weight: 600; letter-spacing: .12em;
  text-transform: uppercase; color: var(--n300);
}
[data-portal] .py-confirm-name {
  display: block; margin-top: 6px;
  font-size: 19px; font-weight: 800; letter-spacing: -.015em; color: var(--gold-hi);
}
[data-portal] .py-confirm-warn { margin-top: 8px; font-size: 12.5px; color: var(--n400); line-height: 1.45; }
[data-portal] .py-confirm-ok { margin-top: 12px; }
[data-portal] .py-err { margin-top: 10px; font-size: 12.5px; color: var(--danger); line-height: 1.45; }

/* ── The scanner ───────────────────────────────────────────────────────────
   Full-bleed camera with a square reticle. Square because everything here is,
   and because a rounded frame over a QR — itself a grid of squares — reads as
   a filter rather than as a target. */
[data-portal] .scan-frame {
  border-radius: var(--radius-lg); border: 3px solid var(--gold); box-shadow: none;
}
[data-portal] .scan-tip { font-size: 14px; font-weight: 700; }
[data-portal] .scan-close {
  border-radius: var(--radius-sm);
  border: 1px solid rgba(255, 255, 255, .4);
  background: rgba(255, 255, 255, .12);
  font-weight: 800;
}

/* ── The notification feed ─────────────────────────────────────────────────
   Push is missable — phone off, alert swiped, battery dead on the way to the
   job. This is the record, so "nobody told me" has an answer. */
[data-portal] .notif-list { margin-top: 4px; }
[data-portal] .notif {
  display: flex; gap: 14px; align-items: flex-start;
  padding: 15px var(--sp-wrap-x);
  border-top: 1px solid var(--hair);
  background: none;
}
/* Unread is the one thing on this screen that is still owed attention. */
[data-portal] .notif.is-unread { background: var(--gold-soft); }
[data-portal] .notif-main { flex: 1; min-width: 0; }
[data-portal] .notif-title { font-size: 14.5px; font-weight: 800; letter-spacing: -.01em; }
[data-portal] .notif-body { margin-top: 3px; font-size: 12.5px; color: var(--n300); line-height: 1.45; }
[data-portal] .notif-when { margin-top: 5px; font-size: 11px; color: var(--n200); }
/* `--tap`, not 34px. The shared rule already sized this to the 40px tap floor
   and squaring it here quietly shrank it — on the CUSTOMER account as well as
   the worker's, because this block is scoped to `[data-portal]` like the rest.
   scripts/sweep-account.js caught it; the icon inside is 14px either way, so
   the smaller box bought nothing and cost six pixels of target. */
[data-portal] .notif-x {
  flex: 0 0 auto; width: var(--tap); height: var(--tap);
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 0; border-radius: var(--radius-sm); color: var(--n200); cursor: pointer;
}
[data-portal] .notif-x:hover { background: rgba(255, 255, 255, .06); color: var(--text); }

/* ── The confirm sheet ─────────────────────────────────────────────────────
   Square, and it asks once. */
[data-portal] .sheet-head { margin-bottom: 10px; }
[data-portal] .sheet-title { font-size: 21px; font-weight: 800; letter-spacing: -.02em; }
[data-portal] .sheet-actions { display: flex; gap: 10px; margin-top: 20px; }
[data-portal] .sheet-actions .btn { flex: 1; }

/* A row that is an offer rather than a setting. */
[data-portal] .row-prompt { background: var(--gold-soft); }

/* ── The price, itemised ───────────────────────────────────────────────────
   ONE BLOCK, TWO AUDIENCES, and that is the whole design of it. The worker
   sees it filling in as they type; the client sees the finished thing on the
   booking they are paying. Same markup, same rules, same figures — because a
   worker who is shown one total and a client who is charged another is the
   worst bug this product could have, and the surest way to avoid it is for
   there to be one drawing of it.

   NO BOX. A price breakdown is a small table and this system draws a table
   with air and one hairline before the total, not with a border round the
   outside. */
[data-portal] .price-form { margin-top: 4px; }
[data-portal] .price-sum { margin-top: 22px; }

/* ROOM BETWEEN THE LINES. At 7px these sat as a solid block of text: three
   figures a client is checking against a total, run together closely enough
   that the eye has to do the separating. 13px gives each line its own space
   without turning four rows into a scroll. Same figures as the app's
   MoneyRow — one breakdown, two clients, one rhythm. */
[data-portal] .money-row {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 16px; padding: 13px 0;
}
[data-portal] .money-label { font-size: 14px; color: var(--n400); min-width: 0; }
/* The note clears the next label now instead of nearly touching it. */
[data-portal] .money-note {
  display: block; margin-top: 4px;
  font-size: 11.5px; color: var(--n200); line-height: 1.4;
}
[data-portal] .money-value {
  flex: 0 0 auto;
  font-size: 14px; font-weight: 600; color: var(--text);
  font-variant-numeric: tabular-nums;
}
/* The last line, and the only figure anyone is actually asked to pay. Weight
   and size carry it (rule 3) — it is not gold, because nothing is owed until
   the client is asked, and on the client's own screen the Pay button is the
   thing the accent belongs to. */
[data-portal] .money-row.is-total { align-items: baseline; padding-top: 12px; }
[data-portal] .money-row.is-total .money-label {
  font-size: 15px; font-weight: 800; color: var(--text);
}
[data-portal] .money-row.is-total .money-value {
  font-size: 22px; font-weight: 800; letter-spacing: -.02em;
}
/* The hairline above the total, and nothing else in the block. */
[data-portal] .price-sum .rule,
[data-portal] .price-block .rule { margin: 8px 0 0; }

/* On the client's booking, the same block with the section's own gutter. */
[data-portal] .price-block { padding: 22px var(--sp-wrap-x); }
[data-portal] .price-none {
  font-size: 13px; color: var(--n300); line-height: 1.5; max-width: 46ch;
}
/* THE SETTLED TOTAL. A figure somebody has already parted with, so it reads as
   a statement of fact rather than as a bill: the label at prose weight, the
   figure at the weight the rest of the system gives a number that matters. */
[data-portal] .price-total {
  display: flex; align-items: baseline; justify-content: space-between; gap: 16px;
}
[data-portal] .price-total-l { font-size: 15px; font-weight: 800; color: var(--text); }
[data-portal] .price-total-v {
  font-size: 22px; font-weight: 800; color: var(--text);
  letter-spacing: -0.4px; font-variant-numeric: tabular-nums;
}

/* A line that STATES the next step rather than offering it — the button under
   it is the control. Same construction as the account's arrival-code cue, with
   the affordances taken off. */
[data-portal] .lv-cue.is-static { cursor: default; }
[data-portal] .lv-cue.is-static:hover { background: rgba(242, 203, 87, 0.07); }

/* Arriving at Jobs from the job on Home. The block is scrolled into view and
   held between two gold rules for a couple of seconds, so the eye lands on the
   one that was tapped rather than at the top of a list a dozen long. */
@media (min-width: 1280px) {
  :root[data-portal] { --wrap: 760px; }
}

/* A phone in landscape has almost no height. The 40px display and the
   masthead's padding would take a third of it before anything is said. */
@media (max-height: 460px) and (orientation: landscape) and (max-width: 899px) {
  :root[data-portal] { --fs-display: 28px; }
  [data-portal] .mast { padding-top: 12px; padding-bottom: 12px; }
}

/* Everything that moves, stops. */
@media (prefers-reduced-motion: reduce) {
  [data-portal] * { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

/* ══ THE WORKER PANEL ══════════════════════════════════════════════════════
 *
 * Everything below is scoped to `[data-portal="worker"]`, which is the whole
 * point: the customer account and the worker portal share one Swiss layer, and
 * these are the two places the worker side deliberately parts company with it.
 *
 * ── 1. THE ACCENT IS INK, NOT FURNITURE ──────────────────────────────────
 *
 * The shared layer has one construction for "the loudest thing on this
 * screen": a faint gold ground between a 2px gold rule above and below. It is
 * right on the client side, where it appears once per screen — on the arrival
 * code, on the current row of a booking's status.
 *
 * On the worker side it had spread to six: the earnings hero, the ID card, the
 * job message, the payout confirmation, the focused job card and the live-job
 * cue. Six gold brackets in one panel is not emphasis, it is wallpaper, and it
 * reads as generic because it is — a gold bar across a dark screen is what
 * every dark dashboard does.
 *
 * So in the worker portal the accent is TYPE, a DOT, and the FILL behind a
 * tag, and never a rule around a block. The faint gold ground stays where the
 * block genuinely is the one thing on the screen; the bars come off. What
 * marks a block now is the size of what is in it and the air around it, which
 * is what the app's worker screens do (see the note in its earnings screen).
 */
[data-portal="worker"] .earn-hero,
[data-portal="worker"] .idcard,
[data-portal="worker"] .job-msg,
[data-portal="worker"] .py-confirm,
[data-portal="worker"] .lv-cue,
[data-portal="worker"] .card-gold,
[data-portal="worker"] .notice {
  border-top: 0;
  border-bottom: 0;
}
/* A message a worker has to FIND in a hurry keeps an edge, because a failure
   that reads as ordinary body copy is one somebody scrolls past. A hairline,
   not a bar, and only on the error. */
[data-portal="worker"] .job-msg.is-err {
  border-top: 1px solid rgba(255, 138, 128, .45);
  border-bottom: 1px solid rgba(255, 138, 128, .45);
}
/* The hero gives up its bars and takes the air instead: the figure inside it
   is 46px and does not need a box to be found. */
[data-portal="worker"] .earn-hero { padding-top: 26px; padding-bottom: 28px; }
[data-portal="worker"] .idcard { padding-top: 26px; padding-bottom: 26px; }

/* ── 2. THE PORTRAIT, top left of every screen ────────────────────────────
 *
 * The handle that opens the account. A CIRCLE, which is rule 1's one deliberate
 * exception in this system: everything else is a container and a face is not
 * one. The gold ring is what makes a 34px photo read as a control rather than
 * as an image that happens to be there.
 */
/* 40×40, WHICH IS THE TAP FLOOR, with the gold ring drawn on the FACE inside
   rather than on the button itself. The button was 36 and the ring WAS the
   button, so the whole control sat 4px under the minimum on every worker
   screen at every phone width; check-responsive and check-type-floors both
   found it. The visible circle is unchanged; what grew is the part a thumb
   can miss. */
[data-portal="worker"] .wmast-portrait {
  position: relative;
  flex: 0 0 auto;
  width: 40px; height: 40px;
  margin-right: 10px; padding: 0;
  background: none; border: 0; border-radius: 999px;
  cursor: pointer;
  display: grid; place-items: center;
}
[data-portal="worker"] .wmast-face {
  /* A REAL BORDER, not a spread box-shadow. The first version drew the ring
     with `box-shadow: 0 0 0 2px`, which is a ring in appearance and a SHADOW
     to anything measuring computed styles: check-swiss flagged it on all
     eleven worker screens, and it was right to. Rule 2 has no "but it looks
     like a border" clause. `box-sizing: border-box` keeps the circle 32px. */
  box-sizing: border-box;
  width: 32px; height: 32px; border-radius: 999px;
  background: var(--surface-strong) center/cover no-repeat;
  border: 2px solid var(--gold);
  display: grid; place-items: center;
  font-size: 12px; font-weight: 800; letter-spacing: .3px;
  color: var(--gold);
  overflow: hidden;
}
[data-portal="worker"] .wmast-portrait:hover .wmast-face { filter: brightness(1.12); }
[data-portal="worker"] .wmast-portrait:focus-visible {
  outline: 2px solid var(--gold); outline-offset: 3px;
}
/* The drawer's badge. Anything waiting inside it has no other way to say so
   now that Account is not a tab. */
[data-portal="worker"] .wmast-dot {
  /* Pinned to where the FACE is, not to the button's corner: the button now
     carries 4px of invisible tap area on each side, and a dot hung off that
     would float away from the circle it belongs to. */
  position: absolute; top: 1px; right: 1px;
  width: 11px; height: 11px; border-radius: 999px;
  background: var(--gold); border: 2px solid var(--bg);
}

/* ── 3. The drawer ────────────────────────────────────────────────────────
 *
 * It covers the tab bar on a phone and the rail on a desktop, deliberately: a
 * panel that leaves the navigation showing reads as one that failed to open,
 * and it puts two navigations on screen at once.
 */
[data-portal="worker"] .wdrawer {
  position: fixed; inset: 0;
  z-index: 90;
}
[data-portal="worker"] .wdrawer-scrim {
  position: absolute; inset: 0;
  background: rgba(0, 0, 0, .66);
  opacity: 0;
  transition: opacity var(--motion-control) ease;
}
[data-portal="worker"] .wdrawer.is-open .wdrawer-scrim { opacity: 1; }
[data-portal="worker"] .wdrawer-panel {
  position: absolute; inset: 0 auto 0 0;
  width: min(340px, 86vw);
  background: #101116;
  /* A hairline on the open edge and NO SHADOW. Rule 2 says separation is a
     rule or air, and the sliver of dimmed app to the right is already doing
     the separating; a drop shadow here would be the one floating thing in the
     product. */
  border-right: 1px solid var(--rule);
  overflow-y: auto; overscroll-behavior: contain;
  padding: calc(env(safe-area-inset-top) + 20px) 0 28px;
  transform: translateX(-100%);
  transition: transform var(--motion-control) cubic-bezier(.2, .7, .3, 1);
}
[data-portal="worker"] .wdrawer.is-open .wdrawer-panel { transform: translateX(0); }

[data-portal="worker"] .wd-ident { padding: 0 var(--sp-wrap-x) 22px; }
[data-portal="worker"] .wd-idbtn {
  display: flex; align-items: center; gap: 13px;
  width: 100%; padding: 0; border: 0; background: none;
  text-align: left; cursor: pointer;
}
[data-portal="worker"] .wd-face {
  /* Same ring, same reason it is a border rather than a spread shadow: see
     the note on .wmast-face. This one sits inside a panel that is `hidden`
     until the drawer opens, so the computed-style audit walked straight past
     it — a rule that only holds because a check could not see the element is
     not a rule that holds. */
  box-sizing: border-box;
  flex: 0 0 auto;
  width: 56px; height: 56px; border-radius: 999px;
  background: var(--surface-strong) center/cover no-repeat;
  border: 2px solid var(--gold);
  display: grid; place-items: center;
  font-size: 20px; font-weight: 800; color: var(--gold);
  overflow: hidden;
}
[data-portal="worker"] .wd-idbody { min-width: 0; }
[data-portal="worker"] .wd-name {
  display: flex; align-items: center; gap: 6px;
  font-size: 19px; font-weight: 800; letter-spacing: -.02em; color: var(--text);
}
[data-portal="worker"] .wd-name svg { width: 15px; height: 15px; color: var(--gold); flex: 0 0 auto; }
[data-portal="worker"] .wd-view { font-size: 12.5px; font-weight: 600; color: var(--n300); margin-top: 3px; }
/* THE CREDENTIAL, not a caption. It is what a client is read at the door and
   what signs this worker in; it used to be grey 12px under their trade, where
   it read as a reference number on an invoice. */
[data-portal="worker"] .wd-cred {
  font-size: 13px; font-weight: 800; letter-spacing: .12em;
  color: var(--gold); margin-top: 18px;
  font-variant-numeric: tabular-nums;
}
[data-portal="worker"] .wd-trade { font-size: 12.5px; color: var(--n300); margin-top: 5px; }
[data-portal="worker"] .wd-safety {
  font-size: 11.5px; line-height: 1.5; color: var(--n200);
  padding: 26px var(--sp-wrap-x) 0;
}

/* ── 4. The masthead a worker screen wears ───────────────────────────────
 *
 * 34px, not the 40px display. The portrait sits above it in the bar and the
 * two together are the top of the screen: at 40 the title outweighed the face,
 * which is the wrong way round on a screen whose first control IS the face.
 */
[data-portal="worker"] .wmast { padding: 18px var(--sp-wrap-x) 16px; }
[data-portal="worker"] .wmast-top {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; min-height: 20px;
}
[data-portal="worker"] .wmast-title {
  font-size: 34px; font-weight: 800; line-height: 1.05;
  letter-spacing: -.028em; color: var(--text); margin: 12px 0 0;
}
[data-portal="worker"] .wmast-meta { font-size: 13px; color: var(--n300); margin-top: 7px; }

/* ── 5. Home's live-work row ─────────────────────────────────────────────
 *
 * What replaced the bracketed hero, the second job row and the "see all"
 * link. The figure changes COLOUR rather than the row changing SHAPE when
 * something needs an answer: a second layout for the urgent case would mean
 * the screen rearranges itself under somebody's hand as jobs land.
 */
[data-portal="worker"] .wlive {
  display: flex; align-items: center; gap: 14px;
  width: 100%; padding: 18px var(--sp-wrap-x);
  background: none; border: 0; text-align: left; cursor: pointer;
  color: inherit;
}
[data-portal="worker"] .wlive:hover { background: rgba(255, 255, 255, .04); }
/* A COLUMN, NOT A LINE OF INLINE SPANS.
 *
 * Everything inside is a `<span>`, and a span is inline. The live-jobs block
 * got away with it because both of its children are `display: flex` and so
 * establish their own line; the OWED block's three children were bare spans,
 * so its eyebrow, its 34px figure and its sub-line all flowed into one
 * paragraph and wrapped through each other — "NEXT PAYOUT · ESTIMATED $0.00
 * Complete a job to start earning", with the figure sitting on top of the
 * label it belongs under. `margin-top` on an inline element does nothing
 * either, which is why the spacing looked absent as well as the order wrong.
 *
 * A flex column here rather than `display: block` on each child: it fixes the
 * whole block at once, it survives a fourth line being added, and it says what
 * the block IS — a stack — instead of patching each thing that is not one. */
[data-portal="worker"] .wlive-b {
  flex: 1; min-width: 0;
  display: flex; flex-direction: column; align-items: flex-start;
}
[data-portal="worker"] .wlive-top { display: flex; align-items: center; gap: 8px; }
[data-portal="worker"] .wlive-fig {
  display: flex; align-items: baseline; gap: 9px; margin-top: 8px;
}
[data-portal="worker"] .wlive-n {
  font-size: 34px; font-weight: 800; letter-spacing: -.032em; line-height: 1;
  color: var(--text); font-variant-numeric: tabular-nums;
}
[data-portal="worker"] .wlive-n.is-lit { color: var(--gold); }
[data-portal="worker"] .wlive-w { font-size: 14.5px; font-weight: 700; color: var(--n400); }
[data-portal="worker"] .wlive-s { font-size: 12.5px; color: var(--n300); margin-top: 6px; line-height: 1.4; }
[data-portal="worker"] .wlive-go { flex: 0 0 auto; width: 18px; height: 18px; color: var(--n200); }
[data-portal="worker"] .wlive-go.is-lit { color: var(--gold); }

/* Home's owed figure, on the same principle as the app's: the number IS the
   design, so it is set large on the page rather than boxed. */
[data-portal="worker"] .wowed-amt {
  font-size: 34px; font-weight: 800; letter-spacing: -.032em; line-height: 1;
  color: var(--gold); margin-top: 8px; font-variant-numeric: tabular-nums;
}

/* ── The booking code on a job card ───────────────────────────────────────
 *
 * The CONTROL is `.code-copy`, which the client's booking screen already had:
 * a copy mark beside the code, quiet until it is hovered, `min-height: var(--tap)`
 * so it clears the tap floor. This first grew a `.job-code` of its own with the
 * same three rules in it, which is how two things that are the same drift into
 * two things that are nearly the same. Only the PLACEMENT differs here, and
 * only the placement is overridden.
 *
 * `.is-inline` sits it in a metadata line under the service name rather than
 * standing alone under a heading, so the client's 12px top margin comes off and
 * the extra tap height is given back with negative margins. The card is the
 * height it was; the target is finger-sized.
 */
[data-portal="worker"] .code-copy.is-inline {
  margin: -10px 0 -11px -6px;
  padding: 0 6px;
  border-radius: var(--radius-xs);
}
[data-portal="worker"] .code-copy.is-inline:hover { background: rgba(255, 255, 255, .05); }
[data-portal="worker"] .code-copy.is-copied { color: var(--gold); }

/* ══ ONE JOB, ON ITS OWN SCREEN ═══════════════════════════════════════════
 *
 * Jobs used to render every job in full — five labelled rows, the stage
 * actions, the price form and a message button, for each of them, stacked.
 * Four jobs was a screen you scrolled through rather than read, and the answer
 * to "what is happening with the solar job" was somewhere in the middle of it.
 *
 * A list is for CHOOSING, so a row carries only what you choose on. The job
 * itself is one tap away.
 */

/* ── A row in the list ──────────────────────────────────────────────────── */
[data-portal="worker"] .jrow {
  display: flex; align-items: center; gap: 12px;
  width: 100%; padding: 16px var(--sp-wrap-x);
  background: none; border: 0; border-bottom: 1px solid var(--hair);
  text-align: left; cursor: pointer; color: inherit;
  /* The press, not a hover: this is a phone first. Backgrounds only — a
     transform on a full-width row makes the whole list feel loose. */
  transition: background-color var(--motion-control) ease;
}
[data-portal="worker"] .jrow:hover { background: rgba(255, 255, 255, .04); }
[data-portal="worker"] .jrow:active { background: rgba(255, 255, 255, .07); }
/* The job waiting on an answer is marked by its PILL, not by a ground behind
   the whole row. A wash reads well on one row in a list of eight and floods
   the screen when two of three jobs are new, which is the ordinary case on a
   busy morning — and an accent covering half a list marks nothing. */
[data-portal="worker"] .pill-stage.is-waiting {
  background: var(--gold); color: #000; font-weight: 800;
}

/* THE ROW IS ARRANGED AGAINST TWO EDGES.
   `align-items: flex-start` here meant every line shrank to its own content
   and started at the left, so the stage pill sat wherever the category ended
   — a different x on every row, which is what read as scattered. `stretch`
   gives all three lines the full width, and the right-hand items then have an
   edge of their own to sit against. */
[data-portal="worker"] .jrow-b {
  flex: 1; min-width: 0;
  display: flex; flex-direction: column; align-items: stretch; gap: 6px;
}
/* NOWRAP, deliberately. Wrapping dropped the pill onto its own line under a
   left-aligned category the moment a name got long ("Air Conditioning
   Services" plus "Needs an answer" plus a badge overflows 360px), and a pill
   hanging under an eyebrow is the scattering at its worst. The category
   truncates instead; the pill never moves. */
[data-portal="worker"] .jrow-top {
  display: flex; align-items: center; gap: 9px; flex-wrap: nowrap;
}
[data-portal="worker"] .jrow-cat {
  flex: 0 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
[data-portal="worker"] .jrow-state {
  flex: 0 0 auto; margin-left: auto;
  display: flex; align-items: center; gap: 8px;
}
/* One line, and an ellipsis rather than a wrap, on all three: a list reads
   down the left edge, and a row that grows to five lines stops the eye doing
   that. Uniform row heights are most of what "arranged" means here. */
[data-portal="worker"] .jrow-service {
  font-size: 16px; font-weight: 700; letter-spacing: -.014em; color: var(--text);
  line-height: 1.25;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The address line and the money share a line, because they are the two halves
   of the same question: who and where, and what it is worth. The amount used
   to be a fourth item stranded under the address on the left edge. */
[data-portal="worker"] .jrow-foot {
  display: flex; align-items: baseline; gap: 12px;
}
[data-portal="worker"] .jrow-sub {
  flex: 1 1 auto; min-width: 0;
  font-size: 12.5px; color: var(--n300); line-height: 1.35;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
[data-portal="worker"] .jrow-amt {
  flex: 0 0 auto;
  font-size: 13px; font-weight: 700; color: var(--n400);
  font-variant-numeric: tabular-nums;
}
[data-portal="worker"] .jrow-go {
  flex: 0 0 auto; width: 16px; height: 16px; color: var(--n200);
}
[data-portal="worker"] .jrow svg { width: 100%; height: 100%; }

/* ── The job screen ─────────────────────────────────────────────────────── */
/* The back control on a screen that carries its own. Used by one job and by
   settings; on the latter it is the ONLY way out, because `bare` has taken the
   shell away. The top padding clears the notch on a screen with no top bar
   above it to do that. */
[data-portal="worker"] .jdet-top {
  display: flex; align-items: center;
  padding: 10px var(--sp-wrap-x) 0;
}
body.chrome-off [data-portal="worker"] .jdet-top,
body.chrome-off .jdet-top { padding-top: calc(14px + env(safe-area-inset-top)); }
/* 34px, not the 40px masthead display size: a service name is prose and runs
   to three or four words, where a screen title is one. */
[data-portal="worker"] .jdet-title { font-size: 30px; line-height: 1.1; }
[data-portal="worker"] .jdet-meta {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 12px;
}
[data-portal="worker"] .jdet-rows { padding: 0 var(--sp-wrap-x) 4px; }
[data-portal="worker"] .jdet-act { padding: 16px var(--sp-wrap-x) 4px; }
[data-portal="worker"] .jdet-chat { padding: 4px var(--sp-wrap-x) 8px; }
[data-portal="worker"] .jdet-tl { padding-bottom: 8px; }
/* Roughly the height the screen will occupy, so nothing jumps when it lands. */
[data-portal="worker"] .jdet-wait {
  display: flex; align-items: center; justify-content: center; padding: 96px 0;
}

/* ── Has the client paid? ───────────────────────────────────────────────────
 * The one question a finished job raises. Four states, separated by weight and
 * by a single edge rather than by four coloured cards: the accent is for what
 * is LIVE and what is OWED, and a job that is settled is neither.
 */
[data-portal="worker"] .jpay {
  display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
  margin: 0 var(--sp-wrap-x); padding: 16px 0 16px 14px;
  border-left: 2px solid var(--hair);
}
[data-portal="worker"] .jpay-t {
  font-size: 15.5px; font-weight: 800; letter-spacing: -.014em; color: var(--text);
}
[data-portal="worker"] .jpay-amt {
  font-size: 26px; font-weight: 800; letter-spacing: -.028em; line-height: 1.05;
  color: var(--text); font-variant-numeric: tabular-nums;
}
[data-portal="worker"] .jpay-n { font-size: 12.5px; color: var(--n300); line-height: 1.45; }

/* Paid: the figure carries it, in the neutral ramp. Money that has arrived is
   not news any more. */
[data-portal="worker"] .jpay-ok { border-left-color: var(--success, #4ADE80); }
/* Owed: gold, because this is precisely what the accent is for. */
[data-portal="worker"] .jpay-wait { border-left-color: var(--gold); }
[data-portal="worker"] .jpay-wait .jpay-amt { color: var(--gold); }
/* Closed with no payment recorded. The one state a worker has to chase. */
[data-portal="worker"] .jpay-warn { border-left-color: var(--danger); }
[data-portal="worker"] .jpay-warn .jpay-t { color: var(--danger); }
/* Nothing due yet, and nothing to look at. */
[data-portal="worker"] .jpay-off .jpay-t { color: var(--n300); font-weight: 700; }

/* ── The lit ledger row, in ink ────────────────────────────────────────────
 *
 * THE ACCENT IS INK ON THIS SIDE. The current step is a faint gold ground
 * between two 2px gold rules, which is right for a CLIENT: Status is the one
 * thing on their booking screen and the construction appears once in the whole
 * account. On the worker panel it is the exact shape the rebuild took off five
 * screens, and the job screen was born carrying it — the no-bracket check
 * never saw it, because `#/job/<id>` was not in the list of screens it walks.
 * It is now, so this had to become true rather than merely unlooked-at.
 *
 * Nothing is lost by it: the 13px gold MARK and the heavier, full-strength
 * label were doing the marking already. What goes is the furniture.
 */
[data-portal="worker"] .tl-row.is-now {
  background: none;
  padding: 13px 0; margin: 0;
  border-top: 1px solid var(--hair); border-bottom: 0;
}
[data-portal="worker"] .tl-row:first-child.is-now { border-top: 0; }
[data-portal="worker"] .tl-row.is-now + .tl-row { border-top: 1px solid var(--hair); }
/* The rail bleeds by exactly the row's padding, so it follows the padding back
   to 13px or it steps in and out at the one row everybody is looking at. */
[data-portal="worker"] .tl-row.is-now .tl-spine { margin-top: -13px; margin-bottom: -13px; }

/* ══ A SCREEN THAT OWNS THE SCREEN ════════════════════════════════════════
 *
 * `chrome-off`: no top bar, no tab bar. Set by EbuPortal.chrome() for every
 * screen the table there calls a TASK — a form, a conversation, a PIN keypad,
 * settings, a payout account, an ID card.
 *
 * WHY. Everything the shell offers is a way to LEAVE, and somebody who has
 * opened one of these has arrived. On settings it is also the screen where a
 * mis-tap costs the most — duty, the payout account, sign out — and the tab
 * bar sat directly under the sign-out row. A back control is the only exit a
 * screen like this needs, and it is IN the screen rather than around it.
 *
 * PHONES AND TABLETS, NOT DESKTOPS, and this settled a disagreement rather
 * than inventing a rule. `bare` (the worker's settings) hid the shell at every
 * width; `chat-open` (a conversation) hid it below 900px only, with the
 * argument written on it: "on a desktop the rail is the navigation and there
 * is nothing to get out of the way of". That argument is right. On a 1440px
 * screen nothing is competing for the space, so taking the navigation away is
 * a loss with no gain — and the two halves of one product behaving differently
 * is the thing this whole pass exists to stop.
 *
 * Deliberately NOT `overflow: hidden` like `chat-open`: a conversation pins
 * itself to the visual viewport because of the keyboard, and settings is an
 * ordinary scrolling page that simply has no chrome.
 */
@media (max-width: 899px) {
  /* The top bar is GONE rather than collapsed here, unlike `chrome-min`: a
     focused screen brings its own header, and that header carries its own
     safe-area padding (see the rule below). Two insets stacked would push the
     content down twice. */
  body.chrome-off .topbar { display: none; }
  body.chrome-off .main { padding-top: 0; }
  /* Nothing is reserving the bottom edge any more. */
  body.chrome-off .wrap { padding-bottom: 40px; }

  /* THE BAR LEAVES, IT DOES NOT BLINK OUT. `transform` composites and reflows
     nothing; `visibility` is what actually takes it out of the tab order and
     the accessibility tree, and it waits for the slide so the bar is still
     there while it moves and gone the instant it lands. The curve and the
     260ms are the scroll auto-hide's, so the bar moves the same way whichever
     reason it is leaving for. */
  body.chrome-off .tabs {
    transform: translateY(100%);
    visibility: hidden;
    transition: transform var(--motion-surface) var(--ease), visibility 0s linear var(--motion-surface);
  }

  /* THE SCREEN'S OWN HEADER NOW CLEARS THE NOTCH, because the top bar was
     doing it. `.detail-head` pads 18px flat, which is right under a bar and
     puts a back button under the camera without one. Only while the chrome is
     off, so nothing changes on the screens that keep their bar. */
  body.chrome-off .detail-head {
    padding-top: calc(18px + env(safe-area-inset-top));
  }
}

/* Someone who asked for less motion still gets the space back — the bar
   arrives and leaves instead of sliding. */
@media (max-width: 899px) and (prefers-reduced-motion: reduce) {
  body.chrome-off .tabs { transition: none; }
}

/* A credential is one word however many hyphens it has. "EBW-56TA6NXZ" wrapped
   at its hyphen on a narrow phone and read as two broken pieces of a string. */
[data-portal] .nobreak { white-space: nowrap; }

/* ══ THE FINER PASS ═══════════════════════════════════════════════════════
 *
 * Both families are VARIABLE (`font-weight: 100 900` on Archivo, `100 700` on
 * IBM Plex), so weight is a continuum here rather than four steps. Everything
 * large on this panel was set at 800, which is a display weight built for a
 * poster — at 34px on a black ground it reads as shouting, and shouting is the
 * opposite of expensive. 740 is still unmistakably bold and has air inside the
 * letterforms; the difference is most of what "premium" means on a screen made
 * almost entirely of type.
 *
 * The tracking tightens with the size, which is what a type designer does by
 * hand and what a scale has to do for you: -.034em at 34px, less as the type
 * gets smaller, none at all under 14px where negative tracking closes counters
 * and costs legibility on a phone in sunlight.
 *
 * Icons went 1.7 → 1.5 in the same pass (the sprites, not here). At 18-20px a
 * 1.7 stroke is heavier than the 600-weight text beside it, so the furniture
 * outweighed the words.
 */
[data-portal="worker"] .wmast-title {
  font-weight: 740; letter-spacing: -.034em;
}
[data-portal="worker"] .jdet-title { font-weight: 740; letter-spacing: -.03em; }
[data-portal="worker"] .wlive-n,
[data-portal="worker"] .wowed-amt { font-weight: 720; letter-spacing: -.036em; }
[data-portal="worker"] .wlive-w { font-weight: 640; }
[data-portal="worker"] .jrow-service { font-weight: 660; letter-spacing: -.012em; }
[data-portal="worker"] .jrow-amt { font-weight: 640; }
[data-portal="worker"] .jpay-t { font-weight: 720; letter-spacing: -.012em; }
[data-portal="worker"] .jpay-amt { font-weight: 720; letter-spacing: -.03em; }
[data-portal="worker"] .wd-name,
[data-portal="worker"] .wowed-lbl { font-weight: 680; }
/* The lit ledger row, and the duty switch's label: the two places on the panel
   where 800 was doing real work at a small size. 700 keeps the step above the
   600 around them without the slab. */
[data-portal="worker"] .tl-row.is-now .tl-label { font-weight: 700; }
[data-portal="worker"] .wsw-label { font-weight: 700; }

/* ══ MOTION ═══════════════════════════════════════════════════════════════
 *
 * ONE MOVE, ON ARRIVAL. A screen fades up 6px over 240ms when it is rendered.
 * That is the whole of it, and the restraint is the point: this is a tool
 * somebody opens forty times a day at a client's door, and an animation you
 * notice on the fortieth open is an animation that is too long. 6px reads as
 * the screen settling rather than as it travelling.
 *
 * `#view` is replaced wholesale on every render, so the animation restarts by
 * itself on a route change with nothing to reset — but that also means it
 * would replay on every background POLL, which is a screen that twitches every
 * few seconds while you read it. `data-anim` is set on a real navigation only
 * (see `go` in worker-dashboard.js) and cleared as soon as it has played.
 *
 * `transform` and `opacity` only: both composite on the GPU and neither can
 * cause a reflow, so nothing else on the page moves while it runs.
 */
@keyframes view-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}
[data-portal] #view[data-anim] {
  animation: view-in .24s cubic-bezier(.22, .61, .36, 1) both;
}

/* The press. A row on a phone has no hover to tell you it is a control, so the
   feedback has to be the touch itself: a ground that arrives instantly and
   leaves slowly, which is the difference between "I pressed that" and "the
   screen flickered". */
[data-portal] .row,
[data-portal] .tab,
[data-portal] .icon-btn,
[data-portal] .chat-row,
[data-portal] .notif-main {
  transition: background-color var(--motion-control) ease, color var(--motion-control) ease;
}
[data-portal] .row:active,
[data-portal] .chat-row:active,
[data-portal] .notif-main:active { transition-duration: 0s; }

/* Nothing moves for somebody who has asked for that. The press feedback stays:
   it is a colour change, not motion, and removing it would leave a control
   with no response at all. */
@media (prefers-reduced-motion: reduce) {
  [data-portal] #view[data-anim] { animation: none; }
}

/* ══ THE FINER PASS, CLIENT SIDE ══════════════════════════════════════════
 *
 * The same argument as the worker layer above, applied to the account: 800 is
 * a display weight built for a poster, and everything large here was set at
 * it. On a screen made almost entirely of type, the weight IS the design, and
 * 800 at 30px on black reads as shouting.
 *
 * SCOPED TO `[data-portal="account"]`, which is the value the customer
 * account sets (views/partials/account-shell-head.ejs) — NOT "client", which
 * is what the codebase calls this person everywhere else and what the first
 * version of this block guessed. A selector that matches nothing is silent.
 *
 * Scoped rather than shared, for two reasons. The worker
 * layer already refines its own and would be overridden by a later shared
 * rule; and the two portals are allowed to differ in the last five per cent
 * even though they share everything else.
 *
 * ONLY DISPLAY SIZES. Nothing under 14px is touched: the small uppercase
 * eyebrows, the pills and the badges all need their weight to hold a shape at
 * 9.5-11px, and thinning them costs legibility on a phone in sunlight for no
 * gain — nobody reads a badge as "heavy".
 */
[data-portal="account"] .mast-title { font-weight: 740; letter-spacing: -.032em; }
[data-portal="account"] .wal-amount { font-weight: 720; letter-spacing: -.038em; }
[data-portal="account"] .pin-title { font-weight: 720; letter-spacing: -.024em; }
[data-portal="account"] .stat-v { font-weight: 720; letter-spacing: -.024em; }
[data-portal="account"] .empty-title { font-weight: 700; letter-spacing: -.018em; }
[data-portal="account"] .h1 { font-weight: 700; }
[data-portal="account"] .rw-t { font-weight: 680; }
[data-portal="account"] .amt { font-weight: 680; }
/* The lit ledger row, which is the one place on the booking screen where the
   step somebody is on has to out-weigh the six around it. 700 keeps the step
   above the 600 of its neighbours without the slab. */
[data-portal="account"] .tl-row.is-now .tl-label { font-weight: 700; }
