/* ==========================================================================
   RIDA CREATION — application chrome.

   Everything here comes from tokens.css. No colour, font size or spacing value
   is written twice. Radius is zero everywhere and there are no shadows — those
   are brand rules, not preferences, and preset components arrive violating both.

   RESPONSIVE MODEL — read this before adding a breakpoint.

   The layout is built MOBILE FIRST and grows. There are three bands and they
   are about the NAVIGATION, not about arbitrary device sizes:

     < 760px    phone      · nav is a drawer   · list and detail are two screens
     760–1099   tablet     · nav is a top row  · list and detail side by side
     >= 1100px  desktop    · nav is a left rail

   Two failures from V1 are structurally prevented here:

   1. NOTHING USES "SCALE TEXT" OR A SCALED UNIT. Every size is an explicit
      px or rem from the tokens. V1's homepage rendered body text at 10.3px
      because an 8px value was multiplied by viewport ÷ 2560.

   2. NO INTERMEDIATE MEDIA QUERY MAY CAP A WIDTH MORE TIGHTLY THAN THE BASE.
      V1 clipped the order status out of the tab strip because a 22ch cap lived
      in an 821–1100px query and the app window sat inside that range. Caps go
      in the base rule and only ever get LOOSER as the screen grows.

   3. NO INPUT IS EVER BELOW 16px ON A TOUCH SCREEN. Safari zooms the whole
      page on focus below that, and the layout never comes back.
   ========================================================================== */

/* ---------------------------------------------------------------- accent
   `--accent-bronze` now comes from tokens.css. Joe, 25 Sep 2026: bronze is
   the brand's PRIMARY accent and papaya the secondary (brand/BRAND.md §5.3),
   so the value moved into brand/tokens.css and this file stopped defining
   it. The original note, from when it was defined here:

   NOT in tokens.css, deliberately — that file is a frozen mirror of the brand
   system, which rules out an accent colour outright for the client-facing
   site ("the stones are the only colour, always inside a photograph"). This
   app is the internal tool, not that site, and Joe asked for a warm contrast
   colour on titles and primary buttons because the all-ink-on-paper chrome
   read as too flat. One value, reused everywhere rather than picked per
   component, so "the accent" stays one colour if it's ever revisited.
   #8B5E34 measured at 5.28:1 against both --paper and --paper-on-ink text —
   comfortably past AA (4.5:1) either as heading text or as a button fill
   under cream text. Kept well clear of --status-error (#8C3A2C, a redder
   rust) so a bronze button is never confused with a danger one. */

/* The resting edge of anything you type into: inputs, selects, textareas and
   the chat composer. NOT cards, tiles or panels — only the boxes a person has
   to find before they can fill them. `--line-strong` (#C9C1B2) measured
   1.68:1 against --paper and `--line` 1.26:1, so an unfocused field all but
   vanished; WCAG 1.4.11 wants 3:1 for the boundary that identifies a control.
   `--ink-muted` is 3.61:1 on paper, 3.31:1 on paper-sunk, and is already the
   brand's token for exactly this — non-text ink: rules, dividers, ornament.
   No new colour. Accessibility audit, 24 Sep 2026. */
:root { --line-field: var(--ink-muted); }

* { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; background: var(--paper); }
/* ⚠ SECOND ATTEMPT at the bottom-gap bug — `height: 100%; height: 100dvh`
   (the first attempt) did NOT fix it; Joe confirmed the gap was still there
   on his own phone. The bug is not a percentage-chain problem `dvh` failed
   to close — it's a documented WebKit quirk specific to a PWA running
   `standalone` from the home screen: `100vh`/`100dvh` under-report the true
   screen height by roughly the home-indicator strip, so ANY height unit on
   `html`/`body`, `dvh` included, comes up short of the real bottom edge —
   which is exactly the residual strip Joe was seeing, painted in whatever
   sits behind the page rather than `--paper`.
   The actual fix is to stop asking a height UNIT to describe the viewport at
   all. `position: fixed; inset: 0` pins body directly to the real, current
   visual viewport edges — the same mechanism used to lock a modal to the
   screen — which iOS keeps correct in standalone mode even when its own
   `dvh` arithmetic doesn't. */
body {
  position: fixed;
  inset: 0;
  /* Matches the manifest's `background_color` and `#app`'s own page ground,
     not `--paper-sunk` (a visibly different, slightly darker shade meant for
     recessed panels) — belt-and-suspenders with the fixed-inset fix above:
     even a residual pixel of html showing through (now also `--paper`, see
     above) reads as "the page," never as an unstyled strip in another tone. */
  background: var(--paper);
  color: var(--ink-secondary);
  font-family: var(--font-text);
  font-weight: var(--weight-light);
  font-size: 15px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  display: flex; flex-direction: column;
  overflow: hidden;
}

/* THIRD ATTEMPT — the one that measures (main.js, `fitScreen`). On an iOS
   home-screen app whose viewport comes up short of the real screen, body
   is sized to the measured screen height instead of the short viewport.
   Scroll areas below pad their last item clear of the home indicator.
   Only with a translucent status bar — see the note above fitScreen. */
html.fitscreen body { bottom: auto; height: var(--app-h); }
/* Everything else pinned to the viewport's bottom edge inherits the same
   short viewport, so it gets the same measured height — the crop dialog's
   backdrop, the phone menu and its scrim, the landscape wall, and the
   sign-in box (whose `inset: 0` is inline in main.js, hence !important). */
html.fitscreen .modal, html.fitscreen #rotate { bottom: auto; height: var(--app-h); }
/* The menu only floats (and so only needs this) as the phone drawer — at
   760px and up it is an ordinary sidebar inside the page, and a forced
   screen height would push its end off the bottom. */
@media screen and (max-width: 759px) {
  html.fitscreen #nav, html.fitscreen #navscrim { bottom: auto; height: var(--app-h); }
}
html.fitscreen #signin-overlay { bottom: auto !important; height: var(--app-h) !important; }

button, input, select, textarea, a { font-family: inherit; color: inherit; }
a { color: var(--ink); text-decoration-thickness: 1px; text-underline-offset: 2px; }
img, svg { max-width: 100%; }

/* Thai UI toggle (Sep 2026). Redefining the two brand font variables on
   `body.lang-th` — rather than touching tokens.css, which is a frozen copy
   and not hand-edited — reaches every element in the app that already reads
   `var(--font-text)` / `var(--font-display)`, with zero effect when the
   toggle is off. Sarabun runs taller than Figtree (stacked tone marks and
   vowels), so the base line-height gets a matching bump; component-level
   line-heights set in px are untouched and were checked by hand at 375px. */
body.lang-th {
  --font-text: 'Sarabun', 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-display: 'Sarabun', 'Cinzel', 'Times New Roman', serif;
  line-height: 1.8;
}

/* Focus is visible everywhere. A 2px inset ink outline, never a browser blue
   halo — the halo is round, and nothing in this system is round. */
:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }

/* #main only ever receives focus programmatically, from the skip link jump
   below — never from Tab, since tabindex="-1" keeps it out of the normal
   order. Drawing the standard focus ring around the entire view for that one
   jump would be worse than no ring at all, so it is suppressed here alone. */
#main:focus { outline: none; }

/* The same for a new view's heading (main.js `focusNewView`) and a dialog box
   that has no field of its own (ui.js) — both take focus from script, only so
   a screen reader starts reading there. iOS counts that as focus-visible and
   drew the ring round "Today" on the Dashboard. Joe, 25 Sep 2026. */
#view h1[tabindex="-1"]:focus, .modalbox[tabindex="-1"]:focus { outline: none; }

/* Skip link — WCAG 2.4.1 Bypass Blocks. The first focusable element in the
   page (see index.html), so a keyboard or screen-reader user can jump past
   the header and every module link straight to the view. Off-screen until it
   takes focus, then shown in brand style — ink on paper, zero radius, no
   shadow — so it reads as this app and not a browser default. */
.skiplink {
  position: fixed; top: -100px; left: 12px; z-index: 1000;
  padding: 10px 16px; border: 1px solid var(--ink);
  background: var(--paper); color: var(--ink);
  font-family: var(--font-text); font-size: 14px; font-weight: 500;
  text-decoration: none;
}
.skiplink:focus { top: 12px; }

/* Icons — see icons.js. `stroke: currentColor` ties every icon to whatever
   color the surrounding text already uses, so this is the only rule they
   need: never let a flex row squeeze one down to an ellipse. */
.icon { flex: none; }

/* ====================================================================== type */

.overline {
  font-size: 11px; font-weight: var(--overline-weight);
  letter-spacing: var(--overline-tracking);
  text-transform: uppercase; color: var(--ink-tertiary);
}
.display {
  font-family: var(--font-display); font-weight: var(--weight-display-strong);
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent-bronze);
}
h1.page { font-size: 20px; }
h2.sect {
  font-family: var(--font-display); font-size: 14px; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink);
  border-bottom: var(--border-width-strong) solid var(--accent-bronze);
  padding-bottom: 5px; margin-bottom: 12px;
  display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
}
/* Allowed to WRAP, not to be clipped. On a phone "Images are not covered by
   the redaction check" is longer than the screen, and nowrap ran it off the
   right edge — a caution you cannot finish reading is worse than none, because
   it looks like it has been dealt with. */
h2.sect .tagmaster {
  font-family: var(--font-text); font-size: 11px; font-weight: 600;
  letter-spacing: 0.16em; color: var(--status-error); text-transform: uppercase;
  border: 1px solid var(--status-error); padding: 3px 6px;
  line-height: 1.5; text-align: right; max-width: 100%;
}
/* The same shape in ink rather than red, for a tag that is GOOD news.
   "Reachable" set in the error colour reads as a warning at a glance, and a
   warning colour that sometimes means "fine" is how the real ones stop being
   seen. */
h2.sect .tagok {
  font-family: var(--font-text); font-size: 11px; font-weight: 600;
  letter-spacing: 0.16em; color: var(--ink-tertiary); text-transform: uppercase;
  border: 1px solid var(--line-strong); padding: 3px 6px;
  line-height: 1.5; text-align: right; max-width: 100%;
}
@media screen and (max-width: 759px) {
  h2.sect { flex-wrap: wrap; }
  h2.sect .tagmaster, h2.sect .tagok { text-align: left; width: 100%; margin-top: 6px; }
}
.fine { font-size: 13.5px; color: var(--ink-tertiary); line-height: 1.65; }
.measure { max-width: var(--measure); }

/* =================================================================== buttons */

.btn {
  font-size: 12px; font-weight: 500; letter-spacing: 0.16em; text-transform: uppercase;
  padding: 10px 16px; border: 1px solid var(--accent-bronze);
  border-radius: var(--radius-lg); corner-shape: squircle; cursor: pointer;
  background: var(--accent-bronze); color: var(--paper-on-ink); white-space: nowrap;
  line-height: 1; display: inline-flex; align-items: center; justify-content: center;
  gap: 7px; min-height: 38px; text-decoration: none;
}
.btn:hover:not(:disabled) { opacity: var(--hover-opacity); }
.btn:disabled { opacity: 0.3; cursor: default; }
.btn.ghost { background: var(--paper); color: var(--ink); border-color: var(--line-strong); }
.btn.small { padding: 7px 11px; font-size: 11px; min-height: 30px; }
.btn.danger { background: var(--status-error); border-color: var(--status-error); }
/* Icon-only — the label lives in `title` instead of on the button, for a
   small, frequent action whose icon is unambiguous on its own (edit, mail,
   password, deactivate, remove). Square rather than the usual pill shape, and
   floored at 30px either side — the same touch-target floor `.small` already
   uses — so the label moving off the face never makes the target harder to
   hit. Never used for a button whose action can't be read from the icon
   alone; those keep their words. */
.btn.iconbtn { padding: 0; width: 30px; min-height: 30px; gap: 0; flex: none; }

/* `[hidden]` and `.btn` are both author-origin selectors of equal
   specificity, so source order decides — and `.btn`'s own `display:
   inline-flex` above silently wins over the UA `[hidden]{display:none}` and
   the attribute renders anyway. Same rule as `.view[hidden]` and
   `.chat-chips[hidden]` elsewhere in this file. Found on the invoices'
   "Quick entry / Full details" toggle (records.js): `hidden` on every OTHER
   doctype's tab bar, still visibly rendered there and wide enough to push
   Save off the right edge of a phone screen — the toolbar looked fine at
   every width this was tested at until a doctype without the button needed
   the space it was silently still taking. */
.btn[hidden] { display: none; }

/* A file picker is a LABEL wrapping (or `for`-pointing at) an `.sr-only`
   file input — never a button calling input.click(), which Joe's Android
   phone ignored, 24 Sep 2026 (see engine/form.js). The label carries the
   look of whatever button class it wears; these rules only restore what a
   <button> gave for free: the pointer, no text selection on a long press,
   and a focus ring when the hidden input inside it has keyboard focus. */
.filepick { position: relative; cursor: pointer; user-select: none; -webkit-user-select: none; }
.filepick:has(input:focus-visible) { outline: 2px solid var(--ink); outline-offset: 2px; }

/* The sign-in password field's own show/hide mark — same "icon riding inside
   the input" shape as `.searchwrap` below, mirrored to the right edge and
   made clickable rather than decorative, since revealing the password is the
   point of it. `padding-right` on the input itself is set inline in main.js,
   next to where the icon's own width is decided, rather than duplicated here. */
.pwwrap { position: relative; }
.pwtoggle {
  position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
  display: flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; padding: 0; border: none; background: none;
  color: var(--ink-tertiary); cursor: pointer;
}
.pwtoggle:hover { color: var(--ink); }

/* Google's own "Sign in with Google" button, deliberately NOT `.btn` — this
   is the one button in the app that isn't Rida's bronze/uppercase brand
   language on purpose. Google's identity guidelines specify the button be
   reproduced close to spec (white, their border colour, sentence case, their
   G logo) rather than restyled to match a host app, so it gets its own class
   built to that spec instead of a `.btn` variant. */
.googlebtn {
  display: inline-flex; align-items: center; justify-content: center; gap: 12px;
  width: 100%; min-height: 40px; padding: 0 12px;
  background: #fff; color: #1F1F1F;
  border: 1px solid #747775; border-radius: 4px;
  font-family: Roboto, arial, sans-serif; font-size: 14px; font-weight: 500;
  letter-spacing: normal; text-transform: none; line-height: 1; cursor: pointer;
}
.googlebtn:hover { background: #F8F9FA; }
.googlebtn:active { background: #F1F3F4; }
.googlebtn svg { flex: none; }

/* ================================================================ app frame */

#app { flex: 1; display: flex; flex-direction: column; min-height: 0; }

#topbar {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 16px; background: var(--paper);
  border-bottom: 1px solid var(--line-strong); flex: none;
  /* viewport-fit=cover means this reports real numbers under a notch. */
  padding-top: max(10px, env(safe-area-inset-top));
}
/* One link home, covering the mark and the name together — see the note in
   index.html. `#topbar`'s own gap only separates this group from the nav
   toggle and the spacer now, so the 12px between the icon and the text is
   restated here. */
.brandlink {
  display: flex; align-items: center; gap: 12px; min-width: 0;
  text-decoration: none; color: inherit;
}
.brandlink:hover .appname { color: var(--accent-bronze); }
#topbar .mark { width: 30px; height: 30px; flex: none; }
#topbar .brand { min-width: 0; }
#topbar .appname {
  font-family: var(--font-display); font-size: 15px; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink);
  margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.spacer { flex: 1; min-width: 0; }
.savestate {
  font-size: 11.5px; letter-spacing: 0.10em; text-transform: uppercase;
  color: var(--ink-tertiary); white-space: nowrap;
}
.savestate.dirty { color: var(--status-error); }
.savestate.saved { color: var(--status-success); }
/* A <button> now, not a <span> — it opens the account menu. Reset to the
   button's own look rather than a native one, and back to background:none
   with a real border so it still reads as the same quiet badge it always
   was, not as a form control. */
#whoami {
  font: inherit; font-size: 11px; font-weight: 600; letter-spacing: 0.16em;
  text-transform: uppercase; color: var(--ink-tertiary); background: none;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg); corner-shape: squircle; padding: 4px 8px;
  white-space: nowrap; cursor: pointer;
}
#whoami:hover, #whoami[aria-expanded="true"] { color: var(--ink); border-color: var(--ink); }

/* The floating menu the badge opens. `position: fixed` with JS-computed
   top/right (see main.js) rather than an absolutely-positioned child of
   #topbar — #topbar's own padding differs across the three breakpoints, and
   computing from the button's actual rect is correct at all three for free,
   instead of three hand-tuned offsets that could each drift independently. */
#acctmenu {
  position: fixed; z-index: 95; background: var(--paper); border: 1px solid var(--ink);
  min-width: 240px; max-width: min(88vw, 320px); padding: 14px; display: grid; gap: 4px;
}
.acctmenu-name {
  font-family: var(--font-display); font-weight: 500; font-size: 15px;
  letter-spacing: 0.06em; color: var(--ink);
}
.acctmenu-role {
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--accent-bronze); margin-bottom: 6px;
}
.acctmenu-note {
  font-size: 13px; line-height: 1.55; color: var(--ink-tertiary);
  padding-top: 8px; border-top: 1px solid var(--line); margin-top: 4px;
}
.acctmenu-item {
  font: inherit; font-size: 12px; font-weight: 500; letter-spacing: 0.14em;
  text-transform: uppercase; text-align: left; background: none; border: none;
  border-radius: 0; color: var(--status-error); padding: 10px 0 0; margin-top: 6px;
  border-top: 1px solid var(--line); cursor: pointer; min-height: 40px;
  display: flex; align-items: center; gap: 7px;
}
.acctmenu-item:hover { opacity: var(--hover-opacity); }


/* The nav's own Sign out — same action, reachable without hunting for the
   badge. Styled like the Settings link above it in #navfoot, not like a
   .btn, so the nav's own visual language stays the nav's. */
.navsignout {
  font: inherit; display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
  padding: 8px 0; margin-top: 4px; background: none; border: none; border-radius: 0;
  color: var(--status-error); font-size: 12.5px; font-weight: 500; letter-spacing: 0.13em;
  text-transform: uppercase; cursor: pointer; min-height: 40px;
}
.navsignout:hover { opacity: var(--hover-opacity); }

/* The Thai/English toggle (Sep 2026), in #navfoot beside Settings and Sign
   out. THIRD design, picked from a side-by-side fitting of three live
   candidates (15 Sep 2026) after the first two — a round bronze knob
   switch matching the currency switch, then a zero-radius segmented pill —
   both ran into the same wall on an iPhone: a `<button>` with any visible
   fill/border-radius of its own is exactly the shape iOS Safari mishandles
   without `appearance: none`, and that kept resurfacing in different
   forms. This one sidesteps the whole problem instead of patching around
   it again: no pill, no track, no button chrome to fight — just two small
   circular flags with tracked Cinzel labels (the same device this app
   already uses for the current tab and the current breadcrumb: colour
   plus a bronze rule) and a sliding underline. The only round shapes left
   are plain `<span>`s, which every browser has always respected. */
.langtoggle {
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
  position: relative; display: inline-flex; gap: 16px; cursor: pointer;
  background: none; border: none; padding: 4px 0 8px; margin-top: 4px;
  font: inherit; color: var(--ink-secondary);
}
.langtoggle:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

.langtoggle .qopt {
  display: flex; align-items: center; gap: 7px;
  font-family: var(--font-display); font-size: 11px; font-weight: 500;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-tertiary);
  transition: color 0.28s ease;
}
.langtoggle[data-value="en"] .qopt.en,
.langtoggle[data-value="th"] .qopt.th { color: var(--ink); }

.langtoggle .qflag {
  width: 15px; height: 15px; border-radius: 50%; overflow: hidden; flex: none;
}
.langtoggle .qflag svg { display: block; width: 100%; height: 100%; }

/* `left`/`width` are set inline by JS off the active `.qopt`'s own measured
   box — the same technique `segToggle()` in invoice-quick.js already uses
   for a sliding fill under variable-width buttons, rather than a fixed
   pixel guess that drifts the moment a label or the tracking changes. */
.langtoggle .qrule {
  position: absolute; bottom: 0; height: 2px; background: var(--accent-bronze);
  transition: left 0.34s cubic-bezier(0.4, 0, 0.2, 1), width 0.34s cubic-bezier(0.4, 0, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  .langtoggle .qrule { transition: none; }
}

/* =================================================================== crumbs

   Tablet and desktop only — Joe's call. On a phone the per-record back button
   (`.backbtn`, below — now shown at every width, not only the phone one) is
   the one way back, and stays the only one there: two navigation idioms doing
   the same job on the smallest screen is worse than one. On the wider bands
   the trail is genuinely useful on top of the back button, because Dashboard
   links straight into a record (`#/orders/<id>`) with no list screen in
   between, and the trail is how you get back past it in one click rather than
   the several the back button alone would take. Empty on boot before the
   first route runs, so it collapses to nothing rather than showing a bar with
   no content in it. */
#crumbbar {
  display: none; align-items: center; gap: 4px; flex: none;
  padding: 0 16px; background: var(--paper-sunk); border-bottom: 1px solid var(--line);
  overflow-x: auto; scrollbar-width: none; white-space: nowrap;
}
@media screen and (min-width: 760px) { #crumbbar { display: flex; } }
#crumbbar::-webkit-scrollbar { display: none; }
#crumbbar:empty { display: none; }
.crumb {
  display: inline-flex; align-items: center; min-height: 32px; padding: 2px;
  font-size: 11.5px; font-weight: 500; letter-spacing: 0.09em; text-transform: uppercase;
  color: var(--ink-tertiary); text-decoration: none; white-space: nowrap; flex: none;
}
a.crumb:hover { color: var(--ink); text-decoration: underline; }
.crumb.current { color: var(--accent-bronze); pointer-events: none; }
.crumbsep { color: var(--ink-muted); font-size: 11.5px; flex: none; }

#navtoggle { display: inline-flex; }
#navtoggle .bars { display: block; width: 15px; height: 9px; position: relative; }
#navtoggle .bars::before, #navtoggle .bars::after,
#navtoggle .bars span {
  content: ''; position: absolute; left: 0; right: 0; height: 1px; background: currentColor;
}
#navtoggle .bars::before { top: 0; }
#navtoggle .bars span    { top: 4px; }
#navtoggle .bars::after  { top: 8px; }

/* ======================================================================= nav

   One <nav> element in three shapes. The markup never changes between bands —
   only where the box sits. That is what stops a phone-only bug existing. */

#shell { flex: 1; display: flex; min-height: 0; }

#nav {
  background: var(--paper); border-right: 1px solid var(--line-strong);
  flex: none; display: flex; flex-direction: column;
  overflow-y: auto; -webkit-overflow-scrolling: touch;
  padding-bottom: env(safe-area-inset-bottom);   /* last link clear of the home indicator */
}
#nav .navgroup {
  font-size: 11px; font-weight: 600; letter-spacing: 0.20em; text-transform: uppercase;
  color: var(--ink-tertiary); padding: 16px 18px 6px;
}
#nav a {
  display: flex; align-items: center; gap: 10px;
  padding: 12px 18px; text-decoration: none; color: var(--ink-secondary);
  font-size: 12.5px; font-weight: 500; letter-spacing: 0.13em; text-transform: uppercase;
  border-left: 3px solid transparent; min-height: 44px;   /* touch target floor */
}
#nav a:hover { background: var(--paper-sunk); color: var(--ink); }
#nav a.on { color: var(--ink); border-left-color: var(--accent-bronze); background: var(--paper-sunk); }
#nav a .count {
  margin-left: auto; font-size: 11.5px; letter-spacing: 0.06em;
  color: var(--ink-tertiary); font-weight: 400;
}
#nav a[aria-disabled="true"] { opacity: 0.42; pointer-events: none; }
#nav a[aria-disabled="true"] .count { font-size: 11px; letter-spacing: 0.14em; }
#navfoot { margin-top: auto; padding: 14px 18px; border-top: 1px solid var(--line); }

#navscrim { display: none; }

/* ==================================================================== views */

#main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }

.view { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; min-height: 0; }
.view[hidden] { display: none; }

/* Pull to refresh — the refresh seal (ptr.js, 25 Sep 2026). The band is fixed
   over the gap that opens above #view as it is pulled down; ptr.js sets its
   top/left/width from #main and its height from the pull. Pinned to the TOP,
   so it does not need adding to the html.fitscreen list. #main clips only
   while a pull is in progress, so the moved view never paints past it. */
#ptr {
  position: fixed; z-index: 40; height: 0; overflow: hidden; pointer-events: none;
  display: flex; align-items: center; justify-content: center;
  background: var(--paper-sunk);
}
#ptr[hidden] { display: none; }
#ptr svg { width: 60px; height: 60px; display: block; overflow: visible; }
#ptr .m-ring { stroke-dasharray: 360 360; }
#ptr .m-cres { stroke-dasharray: 168 360; }
#main.ptr-on { overflow: clip; }
/* ⚠ A two-pane record view (`.split` — list on the left, detail on the
   right) must NOT scroll at the `.view` level. `.records` and `.formwrap`
   each already declare their own `overflow-y: auto` so the list and the open
   record scroll independently — but only if their ancestor chain actually
   stops growing at some height instead of letting `.view` absorb the scroll
   first, which is what was happening: the whole page scrolled as one block,
   dragging the open record's form along with the list underneath it.

   `overflow: hidden` ALONE is not the fix — `.view` is `display: block`, so
   `.split`'s own `flex: 1; min-height: 0` (which is what would actually
   cap it at the view's height) never applied in the first place, because
   `flex` only means anything on a flex CONTAINER's direct children, and
   `.view` was never one. Without `display: flex` here too, `.split` still
   grows to its full content height and hiding the overflow just clips it
   instead of making anything scroll — confirmed the hard way before this
   comment was written. Scoped to `.split` specifically (a dashboard's or
   Settings' own `.wrap` still wants `.view` to scroll normally as one block)
   rather than turned on everywhere. Joe, 8 Sep 2026. */
.view:has(> .split) { display: flex; flex-direction: column; overflow: hidden; }

/* The one width cap in the application, in the BASE rule, never tightened by a
   media query. At 2560px this stops a form running to 2,400px of dead line
   length; at 1100px it does nothing at all. */
.wrap {
  max-width: var(--container-max); margin-inline: auto; width: 100%;
  padding: 20px 16px 48px;
  padding-bottom: max(48px, env(safe-area-inset-bottom));
}
.wrap.narrow { max-width: var(--container-narrow); }

.viewhead {
  display: flex; align-items: flex-end; gap: 12px 16px; flex-wrap: wrap;
  margin-bottom: 18px;
}
.viewhead .titles { min-width: 0; }
.viewhead .actions { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }

/* ============================================================= section boxes

   One box per section: the title, its buttons and its content all inside the
   same border, never a heading or a button sitting loose above or below one.
   Used on Settings, Access and the Dashboard — the pages built as a stack of
   named sections a reader has to tell apart at a glance. `gap` rather than
   per-section margin is what makes the spacing between boxes EQUAL by
   construction: margins between adjacent elements can drift out of step (that
   is exactly how the Settings inconsistency this replaces happened — one
   section carrying its own extra `margin-top` that the others did not), a
   flex gap on one shared container cannot. */
.sectionstack { display: flex; flex-direction: column; gap: var(--space-5); }

/* ================================================================ dashboard */

.tiles {
  display: grid; gap: 14px;
  /* auto-fit + minmax is the whole responsive story for this grid: one column
     on a phone, four on a desktop, and nothing to declare per breakpoint. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
  margin-bottom: 26px;
}
/* Inside a Dashboard section box the tile grid is the box's own last child —
   its padding already provides the space below it, so the grid's own margin
   would double it up. Scoped to `.panel >` so the OTHER `.tiles` use (the
   photo-thumbnail grid on a record's Edit tab, which is not inside a `.panel`
   and still wants its own bottom margin) is untouched. */
.panel > .tiles { margin-bottom: 0; }
.tile {
  background: var(--paper); border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg); corner-shape: squircle;
  padding: 16px; display: flex; flex-direction: column; gap: 4px; min-width: 0;
}
.tile .k {
  font-size: 11px; font-weight: 600; letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--ink-tertiary);
}
.tile .v {
  font-size: 30px; font-weight: 300; color: var(--ink); line-height: 1.1;
  font-variant-numeric: tabular-nums;
}
.tile .v.small { font-size: 20px; }
.tile .v.alert { color: var(--status-error); }
.tile .sub { font-size: 12.5px; color: var(--ink-tertiary); line-height: 1.5; }
.tile.flat { background: var(--paper-sunk); border-style: dashed; border-color: var(--line); }
.tile.flat .v { color: var(--ink-tertiary); font-size: 14px; }

.panels {
  display: grid; gap: 16px;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
}
.panel {
  background: var(--paper); border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg); corner-shape: squircle;
  padding: 16px; min-width: 0;
}
.panel h2.sect { margin-top: 0; }

/* ==================================================== inventory landing page

   Two large, equal, clickable boxes — the Inventory tab's own choice between
   its two registers (`views/inventory.js`). Same border-and-no-shadow
   language as `.panel` above, just larger and an `<a>` rather than a static
   box, so the whole card is the click target rather than a link buried
   inside it. */
.choicecards {
  display: grid; gap: 16px; margin-top: var(--space-5);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
}
.choicecard {
  display: flex; align-items: flex-start; gap: 14px;
  background: var(--paper); border: 1px solid var(--line-strong);
  padding: 22px; color: var(--ink); text-decoration: none;
}
.choicecard:hover { background: var(--paper-sunk); border-color: var(--ink); }
.choicecard-icon { flex: none; margin-top: 2px; color: var(--ink-secondary); }
.choicecard-label { font-size: 17px; font-weight: 500; }
.choicecard-note { font-size: 13px; color: var(--ink-tertiary); margin-top: 3px; line-height: 1.5; }
.choicecard-count {
  font-size: 11px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--ink-tertiary); margin-top: 10px;
}

.rowlist { list-style: none; }
.rowlist li {
  display: flex; align-items: baseline; gap: 8px 10px; flex-wrap: wrap;
  padding: 10px 0; border-bottom: 1px solid var(--line);
}
.rowlist li:last-child { border-bottom: none; }
.rowlist .no {
  font-size: 12px; font-weight: 600; letter-spacing: 0.10em; color: var(--ink);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.rowlist .who { font-size: 14px; color: var(--ink); min-width: 0; overflow-wrap: anywhere; }
.rowlist .meta { font-size: 12px; color: var(--ink-tertiary); margin-left: auto; white-space: nowrap; }
.rowlist a { text-decoration: none; }
/* Horizontal padding matters here even though most callers sit inside a
   container that already insets its content (`.panel`, `.fsection`) —
   `.picklist` (the "Link a client" / "Select a stone" picker modals in
   form.js) does not, so a bare `padding: 12px 0` left "No matching…" text
   flush against the picklist's own border with zero side gap: the same
   text-on-the-box-edge bug as the `.searchwrap input` fix above, just on
   the empty-state row instead of the search input next to it. Found 13 Sep
   2026 by the same 5-agent audit. */
.emptynote { font-size: 13.5px; color: var(--ink-tertiary); padding: 12px 14px; line-height: 1.6; }
/* The one place `.emptynote` carries an icon — see the note in records.js
   for why this is a modifier and not a change to the base rule. */
.emptynote.withicon {
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  text-align: center; padding: 30px 16px;
}
/* `records.js` toggles the `hidden` attribute to show/hide this row as the
   list fills and empties. The browser's own UA rule for that is the
   low-specificity `[hidden] { display: none }` — the class rule above beats
   it on specificity alone and would leave an empty-state icon showing
   underneath a list that already has rows in it. Restated here, at higher
   specificity, so `hidden` still wins. */
.emptynote.withicon[hidden] { display: none; }
.emptynote .emptyicon { color: var(--line-strong); }

/* ============================================================ list + detail

   Two panes on a wide screen, two SCREENS on a phone. `body.on-detail` is set
   by the router and is the only thing that switches between them. */

.split { flex: 1; display: flex; min-height: 0; min-width: 0; }

.listpane {
  width: 330px; flex: none; background: var(--paper);
  border-right: 1px solid var(--line-strong);
  display: flex; flex-direction: column; min-height: 0;
}
.detailpane { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }

.filters {
  padding: 12px; border-bottom: 1px solid var(--line);
  display: grid; gap: 8px; flex: none;
}
.filters input, .filters select {
  width: 100%; padding: 9px 10px; border: 1px solid var(--line-field);
  background: var(--paper); font-size: 16px; color: var(--ink);
  border-radius: var(--radius-lg); corner-shape: squircle;
  min-width: 0;
}
/* Split from the rule above for the same reason `.field select` is — see its
   own comment. Kept as a second, near-identical block rather than one shared
   selector list so each context's base padding (10px here, 8px in `.ftable`)
   stays a single visible number instead of being computed from a variable. */
.filters select {
  padding-right: 34px;
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236E6559' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 10px center; background-size: 15px 15px;
}
/* The search box's magnifying glass. Positioned on the WRAPPER, not the
   input — an `<input>` cannot take children — so the icon floats over the
   input's own left padding rather than sitting beside it. Comes after the
   rule above on purpose: same specificity, and source order is what lets
   this one add the extra left padding the icon needs room in. */
.searchwrap { position: relative; }
.searchwrap .searchicon {
  position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
  color: var(--ink-tertiary); pointer-events: none;
}
/* ⚠ SELF-CONTAINED, NOT LAYERED ON `.filters input` — found 13 Sep 2026 by a
   5-agent audit. This used to be a single `padding-left: 34px` declaration
   that only ADDED the icon's left padding, assuming `.filters input`'s base
   rule (padding/border/background/font-size) was always present on the
   ancestor chain first. That held for every list-pane search (records.js,
   inside `.filters`) but not for the "Link a client" / "Select a stone"
   picker modals (form.js), which reuse `.searchwrap` inside a plain `.modal`
   with no `.filters` ancestor — those two fell all the way back to the
   browser's native, unstyled `<input>`: 13px font instead of 16px, zero
   padding, a raw grey inset border. Text sat flush on the box in exactly
   the two places a person actually complained about, and nowhere else,
   which is why the other list screens looked fine the whole time. Full
   styling here, independent of any ancestor, so a future `.searchwrap`
   used anywhere never has this silent dependency again. */
.searchwrap input {
  width: 100%; padding: 9px 10px 9px 34px; border: 1px solid var(--line-field);
  background: var(--paper); font-size: 16px; color: var(--ink);
  border-radius: var(--radius-lg); corner-shape: squircle;
  min-width: 0;
}
/* The list pane's own "New" button — see the note where it's built in
   records.js. Full width, same as the search box below it, and first in the
   pane so it is the first thing reachable on any module at any width. */
.newbtn { width: 100%; }
/* Stacked, not side by side — two selects sharing the ~300-400px listpane
   width left each one under 165px, which clips a label like "Status — in
   stock first" or "Returned to supplier" with no ellipsis, just a hard cut.
   The phone breakpoint used to override this same rule to fix the identical
   squeeze on a 375px screen; the override is gone now because the base rule
   it was correcting no longer needs correcting. Joe, 8 Sep 2026. */
.filterrow { display: grid; grid-template-columns: minmax(0,1fr); gap: 8px; }

/* Trello-style cards, not a divided list — each entry is its own bordered
   box with a gap around it, stacked in one scrollable column. Joe's own
   words: "cards that I can scroll through like Trello." Applied to every
   doctype's list, not only the ones with photos, so the pane reads as one
   visual language rather than photo-lists and text-lists looking like two
   different apps living side by side. */
.records { list-style: none; overflow-y: auto; flex: 1; min-height: 0;
           display: flex; flex-direction: column; gap: 10px; padding: 10px;
           /* the last card clear of the iPhone's home indicator */
           padding-bottom: max(10px, env(safe-area-inset-bottom)); }
/* flex-shrink:0 is load-bearing, not decoration. `.records` is a COLUMN flex
   container with a constrained height (`flex:1` inside the listpane) — once
   a list is long enough that every card's natural height adds up to more
   than that (Inventory, at 2,222 rows, is the first list ever long enough to
   hit this), flexbox's default behaviour is to SHRINK each item to fit
   rather than let `overflow-y: auto` on `.records` do its job. This
   `min-height: 44px` looks like a floor but is actually the shrink target:
   without an explicit flex-shrink, a numeric min-height overrides the
   flexbox spec's own min-height:auto default (which exists specifically to
   stop content-sized items shrinking below their content) — so every card
   got squashed to exactly 44px and `overflow: hidden` clipped the rest,
   which is what Joe saw as ghosted, overlapping text on every row. Diagnosed
   by measuring: the row itself computed to 44px tall while its own
   thumbnail (52px) and text stack (66-70px) both wanted more. Joe, 8 Sep
   2026. */
.records li {
  padding: 12px 14px; border: 1px solid var(--line-strong); cursor: pointer;
  display: block; min-height: 44px; flex-shrink: 0; background: var(--paper); overflow: hidden;
}
.records li:hover { background: var(--paper-sunk); }
.records li.on { background: var(--ink); border-color: var(--ink); }
.records li.on .r-no, .records li.on .r-main { color: var(--paper-on-ink); }
.records li.on .r-sub { color: var(--paper-on-ink-tertiary); }
.records li.on .pill  { color: var(--paper-on-ink); }

/* The card's own cover photo — full width, filled edge to edge, THE image on
   a photo-card rather than a small side thumbnail. Only rows for a doctype
   that CAN carry a photo get `.has-thumb` (see records.js) — Clients,
   Expenses and Payments stay plain text cards, exactly as before, just
   boxed like every other card now. 4:3 matches `.thumbframe` on the Edit
   tab, so a stone reads at the same shape everywhere it appears. `.r-thumb`
   carries its own border and fill independently of the `<img>` inside it —
   a missing or evicted photo (`.img-broken`, see ui.js) still leaves a
   clean neutral rectangle rather than a broken-image icon or a collapsed
   card. The card's own padding moves to `.r-body` below the photo, so the
   cover can reach every edge of the card instead of sitting inset in it. */
.records li.has-thumb { padding: 0; }
.r-thumb {
  position: relative;   /* applyFocus places the photo absolutely — the crop */
  width: 100%; aspect-ratio: 4 / 3; background: var(--paper-sunk);
  border: 0; border-bottom: 1px solid var(--line-strong); overflow: hidden;
}
.r-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* No photo yet, versus a photo that failed to load — see the note in
   records.js. Centered rather than filled, so the box still reads as an
   empty slot and not a mistake. */
.r-thumb.empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
}
.r-thumb .r-thumb-icon { color: var(--line-strong); }
.records li.on .r-thumb { border-color: var(--paper-on-ink-tertiary); }
.r-body { min-width: 0; padding: 12px 14px; }

/* Inventory's own row shape, Joe's call 8 Sep 2026: a register is scanned as
   a list, not browsed as a gallery, so its rows get a small thumbnail beside
   the text instead of the full-width cover photo every other list uses.
   `.compact` is opt-in per view (`compactThumb` in the recordView config —
   see records.js), never a doctype property, so this is the two inventory
   registers only. Overrides `.has-thumb`'s own block layout rather than
   replacing it, so everything about `.r-body`'s CONTENT (`.r-top`, `.r-no`,
   `.r-main`, `.r-sub`) stays exactly as every other list already draws it. */
.records li.has-thumb.compact {
  display: flex; align-items: center; gap: 12px; padding: 8px 14px;
}
.records li.has-thumb.compact .r-thumb {
  width: 52px; height: 52px; flex: none; aspect-ratio: auto;
  border: 1px solid var(--line-strong);
}
.records li.on.has-thumb.compact .r-thumb { border-color: var(--paper-on-ink-tertiary); }
.records li.has-thumb.compact .r-body { padding: 0; flex: 1; }

.r-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* `.r-no` is a link in a record list (records.js) — the row stays the visual
   target, so no underline. */
a.r-no { text-decoration: none; }
.r-no {
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.12em; color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.r-main { font-size: 14.5px; color: var(--ink); margin-top: 3px; overflow-wrap: anywhere; }
.r-sub {
  font-size: 12px; color: var(--ink-tertiary); margin-top: 3px;
  display: flex; gap: 5px; flex-wrap: wrap; align-items: center;
}
.r-sub .sep::before { content: '·'; margin-right: 5px; color: var(--ink-muted); }
.records li.is-late { box-shadow: inset 3px 0 0 var(--st-hold); }
.records li.st-cancelled:not(.on) { background: var(--paper-sunk); }
.records li.st-cancelled .r-no, .records li.st-cancelled .r-main { color: var(--ink-tertiary); }

.pill {
  display: inline-block; font-size: 11px; font-weight: 600; letter-spacing: 0.14em;
  text-transform: uppercase; padding: 2px 6px; border: 1px solid currentColor;
  border-radius: var(--radius-lg); corner-shape: squircle;
}
.pill.p-late { color: var(--st-hold); }
.pill.p-rush { color: var(--st-making); }
.pill.p-vip  { color: var(--st-committed); }

/* ============================================================ status colour

   SCREEN ONLY. The colour is carried by the DOT, never by the text: these hues
   run 3.4–6:1 on paper and tokens.css is explicit that anything under 4.5 is
   non-text only. The status is spelled out in words beside the dot regardless,
   so colour is a second channel and never the only one — which is also what
   makes it safe for anyone who cannot separate the ochre from the green.

   Warm-shifted to sit with the brand ink rather than a stock traffic light.
   Defined HERE and deliberately not in tokens.css: the frozen tokens are the
   brand, and a printed sheet must keep printing the way it always did. */
:root {
  --st-draft:     #8A8175;
  --st-committed: #3E5A73;
  --st-making:    #8C5C16;  /* 5.41:1 on paper, 4.96:1 on paper-sunk — was #A9711C, 3.91:1, failed AA as the Rush pill's text */
  --st-ready:     #3F5C42;
  --st-delivered: #241C10;
  --st-hold:      #8C3A2C;
  --st-cancelled: #B5AC9D;
}
.statusmark { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.statusmark .dot {
  width: 7px; height: 7px; flex: none; border-radius: 50%;  /* the one round thing
                                                               in the app: a square
                                                               7px mark reads as a
                                                               rendering artefact */
  background: var(--dot, var(--ink-muted));
}
.statusmark.g-draft     { --dot: var(--st-draft); }
.statusmark.g-committed { --dot: var(--st-committed); }
.statusmark.g-making    { --dot: var(--st-making); }
.statusmark.g-ready     { --dot: var(--st-ready); }
.statusmark.g-delivered { --dot: var(--st-delivered); }
.statusmark.g-hold      { --dot: var(--st-hold); }
.statusmark.g-cancelled { --dot: var(--st-cancelled); text-decoration: line-through; }
/* Draft is a hollow ring — nothing has happened yet. */
.statusmark.g-draft .dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--st-draft); }
.records li.on .statusmark.g-delivered { --dot: var(--paper-on-ink-tertiary); }

/* Written at (0,2,1) on purpose. `select.statussel` is (0,1,1) and LOSES to
   `.field select` further down this file on source order alone — which is
   exactly the bug that killed the status colour in V1's built app while
   looking fine in a browser. Specificity in this file is load-bearing. */
.field select.statussel { border-left: 3px solid var(--ink-muted); }
.field select.statussel.g-draft     { border-left-color: var(--st-draft); }
.field select.statussel.g-committed { border-left-color: var(--st-committed); }
.field select.statussel.g-making    { border-left-color: var(--st-making); }
.field select.statussel.g-ready     { border-left-color: var(--st-ready); }
.field select.statussel.g-delivered { border-left-color: var(--st-delivered); }
.field select.statussel.g-hold      { border-left-color: var(--st-hold); }
.field select.statussel.g-cancelled { border-left-color: var(--st-cancelled); }

/* ===================================================================== tabs */

.tabs {
  display: flex; align-items: center; gap: 6px; flex: none;
  padding: 8px 12px; background: var(--paper); border-bottom: 1px solid var(--line-strong);
  overflow-x: auto; scrollbar-width: none;
  /* Settings alone has 7 of these (Company…Session) and does not fit a phone
     width; a scrollable row with its scrollbar hidden and no other cue reads
     as "that's everything" rather than "swipe for more". Same standing fade
     as #nav in the tablet band — cheap, and inert when nothing overflows. */
  mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
  -webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
  font-size: 11.5px; font-weight: 500; letter-spacing: 0.14em; text-transform: uppercase;
  padding: 9px 12px; border: 1px solid transparent; background: none; cursor: pointer;
  color: var(--ink-tertiary); white-space: nowrap; min-height: 36px;
  /* Also used as an <a> now (Settings' own sub-navigation), which carries an
     underline by default that a <button> never did. */
  text-decoration: none; display: inline-flex; align-items: center;
}
.tab:hover { color: var(--ink); }
.tab.on { color: var(--accent-bronze); border-color: var(--accent-bronze); background: var(--paper-sunk); }
/* Shown at EVERY breakpoint, not just the phone — Joe's call: a person who
   opened a record should have one obvious way back on any screen, rather
   than a control that only exists below 760px and has to be explained. Tied
   to `body.on-detail` (set by the router whenever a record is open, at every
   width — see records.js) rather than to a breakpoint, so on tablet/desktop
   it only appears once there is something to go back FROM; on a phone the
   whole detail pane is already hidden at the welcome screen, so this rule
   alone is enough there too. */
.backbtn {
  display: none; background: none; border: none; cursor: pointer;
  font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--ink); padding: 9px 10px 9px 0; min-height: 36px; white-space: nowrap;
}
body.on-detail .backbtn { display: inline-block; }
.recmeta {
  font-size: 11.5px; letter-spacing: 0.10em; text-transform: uppercase;
  color: var(--ink-tertiary); white-space: nowrap; overflow: hidden;
  text-overflow: ellipsis; max-width: 34ch;
  /* Shrinks (to nothing if it must) rather than pushing Save off the right
     edge — at tablet width the invoice toolbar ran 100px past its pane,
     26 Sep 2026. It only repeats what the page already shows. */
  flex-shrink: 1; min-width: 0;
}

/* ===================================================================== form */

.formwrap { flex: 1; overflow-y: auto; min-height: 0; }
.form { max-width: 1000px; margin-inline: auto; width: 100%; padding: 20px 16px 60px; }

.fsection { margin-bottom: 26px; }
/* Removed 4 Sep 2026 when field-level help moved behind the "i" in each
   label, then found still live at 5 section-level call sites (dashboard.js,
   clients.js, access.js x2, settings.js) by the 13 Sep 5-agent audit — those
   render a `div.formnote` directly as visible caption text, not through the
   tooltip system this rule was removed for. Without it the note fell back to
   plain body text with no margin, so it ran straight into whatever followed
   with zero gap. Restored verbatim (commit 8b75719 had the original). */
.formnote {
  font-size: 12.5px; line-height: 1.6; color: var(--ink-secondary);
  border-left: 2px solid var(--line-strong); padding: 2px 0 2px 11px;
  margin: -4px 0 16px; max-width: 78ch;
}

.fgrid { display: grid; gap: 14px; }
.fgrid > * { min-width: 0; }
/* `.fgrid` sets its own `display: grid` (an author rule), which wins over the
   UA `[hidden]{display:none}` regardless of source order or specificity —
   same issue as `.btn[hidden]`, `.view[hidden]` and `.chat-chips[hidden]`
   below. Needed from 16 Sep 2026: the quick-invoice Client subview toggles a
   whole `.fgrid` block hidden (the shipping-address row) rather than just the
   textarea inside it — see invoice-quick.js customerView(). */
.fgrid[hidden] { display: none; }

/* Labels bottom-align, controls sit on one line, hints start together. Left as
   three separate blocks, a label that wrapped to two lines pushed its own
   control ten pixels below every other control on the row. */
@supports (grid-template-rows: subgrid) {
  .fgrid { grid-auto-rows: auto; }
  /* TWO rows, not three. The third used to be the help text under each field;
     that is now behind the "i" in the label, so a third row here would be an
     empty band of nothing under every control on the form. */
  .fgrid > .field {
    display: grid; grid-row: span 2; grid-template-rows: subgrid; align-content: start;
    /* Subgridding inherits the PARENT's row gap (14px, `.fgrid`'s own `gap`)
       between the two rows a subgrid item itself owns — the label row and the
       control row — which is a gap meant for space BETWEEN fields, not inside
       one. Left unset, a field inside `.fgrid` read as an 18px label-to-input
       gap while a standalone `.field` elsewhere read as 4px (the label's own
       `margin-bottom`), for no reason a user could see. Found 16 Sep 2026 on
       the quick-invoice Client subview. Zeroing it here makes the label's own
       margin the only gap everywhere, on desktop as well as phone. */
    row-gap: 0;
  }
  .fgrid > .field:has(textarea) { grid-template-rows: auto auto; }
}

.field label {
  display: flex; align-items: center; gap: 0; flex-wrap: wrap;
  font-size: 11px; font-weight: 600; letter-spacing: 0.16em;
  text-transform: uppercase; color: var(--ink-tertiary); margin-bottom: 4px;
  align-self: end; min-height: 17px;
}
.field label .m { color: var(--status-error); margin-left: 5px; }
.field label .ccy { color: var(--ink-tertiary); margin-left: 5px; letter-spacing: 0.10em; }
.field input, .field textarea {
  width: 100%; padding: 9px 10px; border: 1px solid var(--line-field);
  border-radius: var(--radius-lg); corner-shape: squircle; background: var(--paper);
  /* 16px is a hard floor, not a preference — Safari zooms the page on focus
     below it and the layout never comes back. */
  font-size: 16px; color: var(--ink); min-width: 0;
}
.field textarea { min-height: 78px; resize: vertical; line-height: 1.6; font-weight: 300; }
/* Select is its OWN rule, not grouped with input/textarea above — Joe's
   correction, 4 Sep 2026: the native browser arrow sat flush against the
   9-10px right edge, the same padding the LEFT side uses for text, so the
   chevron read as jammed against the border rather than centred with room
   around it. `appearance: none` drops the native arrow (Safari needs its own
   prefix; Firefox does too, harmlessly, since it already ignores the
   standard property on its own legacy select rendering path) and a drawn
   chevron — same stroke width and colour as every other icon in the app,
   `--ink-tertiary` hard-coded because a CSS custom property cannot be read
   from inside a `url()` data URI — replaces it with real breathing room.
   Every other select in the app (`.ftable select`, `.filters select`) gets
   the identical treatment below, split out of ITS group for the same
   reason, so the arrow inset is consistent everywhere, not just on forms. */
.field select {
  width: 100%; padding: 9px 34px 9px 10px; border: 1px solid var(--line-field);
  border-radius: var(--radius-lg); corner-shape: squircle; background: var(--paper); font-size: 16px; color: var(--ink);
  min-width: 0; appearance: none; -webkit-appearance: none; -moz-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236E6559' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 10px center; background-size: 15px 15px;
}
/* The last inline hint in the app: the caption under a network address in
   Settings. Left visible on purpose — "From anywhere" versus "Same wifi only"
   is the thing people get wrong, and it has to be readable while comparing the
   two addresses, not one at a time behind a tooltip. */
.hint { font-size: 12.5px; color: var(--ink-tertiary); margin-top: 4px; line-height: 1.5; }
.field.alert input, .field.alert textarea { border-color: var(--status-error); }
.field.alert label { color: var(--status-error); }
.field .dupe {
  font-size: 12.5px; color: var(--status-error); margin-top: 5px; line-height: 1.5;
}

.moneypair { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.moneypair input { text-align: right; }
/* The box you TYPED carries the dark edge. The derived one is never dimmed and
   never disabled — some costs are paid in baht and some in dollars, and which
   is which changes line by line. Marking the authoritative figure is honest;
   greying the other one told people the app was one-way, and it is not. */
input.money.typed { border-left: 2px solid var(--ink); font-weight: 500; }

.fxwarn {
  font-size: 13px; color: var(--status-error); border: 1px solid var(--status-error);
  padding: 9px 11px; margin-bottom: 12px; line-height: 1.5;
}

/* ============================================================ field help

   The small "i" beside a label, and the one tooltip it opens. Every line of
   plain-English help in this app is behind one of these — hover and hold on a
   desktop, tap on a phone. See js/tooltip.js for why it is a real button and
   never a `title=""`.

   ⚠ A DELIBERATE ROUND EXCEPTION to "radius is zero everywhere" — Joe's
   correction, 4 Sep 2026: the first version was a square box with an italic
   letter "i" in it, which read as a typo sitting inside a border, not as a
   control. This is a drawn info-circle ICON (icons.js `info`, the same
   `stroke="currentColor"`-and-no-fill treatment as every other icon in the
   app), not a text glyph with a border faked around it — so there is no
   `border-radius` here to be an exception at all, the same way `gem` or
   `settings` two rows up in icons.js are round without anyone calling it out.
   Same standing as `--accent-bronze` just above: the client-facing brand
   system (tokens.css) is untouched: this is the internal tool, and Joe asked
   for it directly. */
.helpmark {
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  flex: none; width: 13px; height: 13px; margin-left: 7px; padding: 0;
  border: 0; background: none;
  color: var(--ink-tertiary); cursor: help;
}
.helpmark .icon { display: block; }
/* ⚠ THE MARK IS SIZED AGAINST ITS LABEL, NOT ON ITS OWN — Joe, 4 Sep 2026:
   "the info icons are sometimes bigger than the text". They were: a 15px icon
   beside an 8.5px tracked-uppercase label, so the help mark was the largest
   thing on a row whose actual content it was only annotating. Both halves
   moved — every micro-label in this file went up (8.5 → 11px, and the rest of
   the scale with it) and the mark came DOWN to 13px. Anything smaller starts
   to lose the dot over the stem at a 1.6 stroke on a 24 viewBox.

   The TAP target is unchanged at 27px, which clears the 24px minimum without
   drawing anything — so shrinking the visible mark did not make it harder to
   hit. A 13px tap target on a phone is a miss every other try; the inset grew
   to -7px to hold the target where it was. */
.helpmark::after { content: ''; position: absolute; inset: -7px; }
.helpmark:hover, .helpmark:focus-visible, .helpmark.on { color: var(--accent-bronze); }
.helpmark:focus-visible { outline: 2px solid var(--accent-bronze); outline-offset: 2px; border-radius: 50%; }

/* Ink on paper, inverted — the same two colours as everything else, so it
   reads as this app and not as a browser tooltip. No shadow and no radius:
   both are brand rules, and the ink ground already separates it from the form
   underneath. `pointer-events: none` so it can never sit between the cursor
   and the field it is describing.

   ⚠ OPACITY/VISIBILITY, NOT display:none/block — Joe, 5 Sep 2026: "Move
   cursor away -> Fade away." `display` cannot be transitioned, so closing
   used to be instant regardless of how it opened. The base rule sits at
   opacity 0 and `visibility: hidden`; `.tip.on` animates opacity up with no
   delay, and REMOVING `.on` animates it back down, with `visibility`
   switching to hidden only once that fade has actually finished — so it
   fades out looking exactly like it fades in, rather than vanishing the
   instant the class comes off. `place()` in tooltip.js still measures the box
   via a temporary INLINE `visibility: hidden` before positioning it, which
   overrides this transition anyway (inline beats a class rule) so the two
   never fight. */
.tip {
  position: fixed; z-index: 200;
  max-width: min(340px, calc(100vw - 16px));
  padding: 9px 12px; border: 1px solid var(--ink);
  background: var(--ink); color: var(--paper);
  font-family: var(--font-text); font-size: 13.5px; font-weight: 300;
  line-height: 1.55; letter-spacing: 0; text-transform: none;
  pointer-events: none;
  opacity: 0; visibility: hidden;
  transition: opacity 160ms ease, visibility 0s linear 160ms;
}
.tip.on { opacity: 1; visibility: visible; transition: opacity 160ms ease; pointer-events: auto; }
/* ↑ `pointer-events: auto` only while OPEN — the mouse can rest on the words
   (WCAG 1.4.13). Closed, it stays `none`, so a faded-out tip never swallows a
   click. It opens ABOVE the "i" (tooltip.js place()), clear of the field. */

/* In the cost build-up the label and its "i" share a line with two money
   boxes, so the mark aligns to the text rather than to the row. */
.costgrid .cl { display: flex; align-items: center; flex-wrap: wrap; }

/* Under an upload button, where there is no label to sit inside.

   NOWRAP and a button allowed to wrap INTERNALLY, which is the opposite of
   `.btn`'s own rule and deliberate — a long category name on a narrow phone
   still needs somewhere to go. */
.uploadslot { display: flex; align-items: stretch; gap: 2px; flex-wrap: nowrap; }
/* ⚠ EQUAL SIZE MEANS WIDTH TOO, NOT ONLY HEIGHT — Joe's correction, 4 Sep
   2026, after the button text shortened to a plus icon and a bare category
   name. `.uploadslot` (the grid cell) was already equal width across the row
   — `.uploadrow` uses fixed equal columns — but the BUTTON inside was
   inline-flex, sized to its OWN content, so "CAD Renders" stayed a visibly
   narrower bordered box than "Client References" sitting right next to it in
   an equal-width but otherwise empty slot. `flex: 1` makes the button itself
   fill the slot, the same fix in spirit as `align-items: stretch` below did
   for height — the CONTROL'S OWN BORDER must match, not just the invisible
   wrapper around it. */
.uploadslot .btn { flex: 1; white-space: normal; line-height: 1.3; min-width: 0; }
/* `align-items` was `center`, which let the button's own border-box stay
   whatever height its text needed and just float centred in the row — a
   one-line button sat shorter than a two-line sibling in the SAME row.
   `stretch` makes every `.btn` in a row fill the row's own height (each
   `.uploadslot` is a CSS Grid item in `.uploadrow` above, and grid items
   stretch to their row by default already — this is what makes the STRETCH
   reach the button's actual border, not just the invisible slot around it).
   The explicit `.btn.small` floor beneath makes rows match EACH OTHER too,
   not only their own two columns.
   `.uploadslot .btn.small` — three classes, specificity (0,3,0) — deliberately
   beats `.btn.small` (0,2,0) alone, which already sets its own `min-height:
   30px` and would otherwise win on specificity regardless of which rule comes
   later in the file. Same trap as `.field select.statussel` above; same fix. */
/* 41px, not 36 — measured at 375px, where "Client References" wraps to two
   lines while "CAD Renders" on the SAME grid row-pair stays one, without
   this floor two different row heights across the 2x2 block. 41px is what
   two lines of this label actually render at; 36px was a guess made before
   remeasuring after the labels shortened, and was wrong. */
.uploadslot .btn.small { min-height: 41px; }
/* The "i" has an explicit 15x15 size, so `stretch` cannot resize it — but its
   OWN alignment within a taller stretched row still needs pinning, or it drifts
   toward the top of the row instead of sitting level with the button text. */
.uploadslot .helpmark { align-self: center; }

/* Tables restack as labelled cards on a phone — see the phone band below. */
.ftable { width: 100%; border-collapse: collapse; }
.ftable th {
  font-size: 11px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ink-tertiary); text-align: left; padding: 5px 6px;
  border-bottom: 1px solid var(--line-strong);
}
.ftable td { padding: 3px 4px; }
.ftable input, .ftable select {
  width: 100%; padding: 8px; border: 1px solid var(--line-field);
  border-radius: var(--radius-lg); corner-shape: squircle; background: var(--paper); font-size: 16px; color: var(--ink);
}
.ftable select {
  padding-right: 30px;
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236E6559' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 8px center; background-size: 15px 15px;
}
.tablescroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }

/* A table too wide for its pane ABOVE the phone band — engine/form.js adds
   `.stacked` when it measures one (26 Sep 2026: the invoice items table
   scrolled sideways on a tablet and a laptop). Same labelled cards as the
   phone band below, but each card lays its fields out in a grid so a
   700px pane shows four or so per line rather than one very tall column.
   The first column (the description) takes a full line of its own. Scoped
   to this band so the phone rules and print are untouched. */
@media screen and (min-width: 760px) {
  .ftable.stacked, .ftable.stacked tbody { display: block; width: 100%; }
  .ftable.stacked thead { display: none; }
  .ftable.stacked tr {
    display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
    gap: 4px 12px; align-items: end;
    border: 1px solid var(--line-strong); background: var(--paper);
    padding: 10px; margin-bottom: 10px;
  }
  .ftable.stacked td { display: block; padding: 5px 0; min-width: 0; }
  .ftable.stacked td:first-child { grid-column: 1 / -1; }
  .ftable.stacked td::before {
    content: attr(data-label); display: block;
    font-size: 11px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
    color: var(--ink-tertiary); margin-bottom: 3px;
  }
  .ftable.stacked td:empty { display: none; }
}

.stonecard {
  border: 1px solid var(--line-strong); background: var(--paper);
  border-radius: var(--radius-lg); corner-shape: squircle;
  padding: 14px; margin-bottom: 14px;
}
.stonecard-head {
  display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px;
  font-size: 11px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--ink-tertiary);
}
.rowdel {
  margin-left: auto; background: none; border: 1px solid var(--line-strong);
  padding: 5px 9px; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
  cursor: pointer; color: var(--ink-tertiary); min-height: 30px;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
.rowdel:hover { color: var(--status-error); border-color: var(--status-error); }

/* The "Select from inventory" strip under a linked stones card. */
.linkrow {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line);
}
.pill.p-linked { color: var(--st-committed); }

.costgrid {
  display: grid; grid-template-columns: minmax(0, 1fr) 132px 132px;
  gap: 10px 14px; align-items: center;
}
.costgrid .cl { font-size: 14px; color: var(--ink); }
/* The Other costs table drawn inside Costs, under the cost lines — see
   renderCosting's `attachTo` block in engine/form.js. */
.attached { margin-top: 12px; }
.costgrid input {
  width: 100%; padding: 8px 9px; border: 1px solid var(--line-field);
  background: var(--paper); font-size: 16px; color: var(--ink);
  text-align: right; border-radius: var(--radius-lg); corner-shape: squircle; min-width: 0;
}
.costgrid .ch {
  font-size: 11px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ink-tertiary); text-align: right;
}
.costgrid .derivedfig {
  text-align: right; font-size: 15px; color: var(--ink-tertiary);
  font-variant-numeric: tabular-nums;
}
.costtotals {
  display: grid; gap: 14px; margin-top: 20px; padding-top: 16px;
  border-top: 1px solid var(--line-strong);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr));
}
.tot > * { min-width: 0; }
.tot .k {
  font-size: 11px; font-weight: 600; letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--ink-tertiary);
}
.tot .v {
  font-size: 19px; font-weight: 500; color: var(--ink); margin-top: 3px;
  font-variant-numeric: tabular-nums;
}
.tot .v.neg { color: var(--status-error); }
.tot .sub { font-size: 12.5px; color: var(--ink-tertiary); margin-top: 2px; }

/* ===================================================================== misc */

.banner {
  border: 1px solid var(--line-strong); background: var(--paper);
  padding: 12px 14px; margin-bottom: 16px; font-size: 13.5px; line-height: 1.6;
  display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap;
}
.banner.warn { border-color: var(--status-error); color: var(--status-error); }
.banner .btn { margin-left: auto; }

/* Read by a screen reader, never seen. The standard clip pattern — not
   `display:none`, which would hide it from the screen reader too. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}
.toast {
  position: fixed; left: 50%; transform: translateX(-50%);
  bottom: max(20px, env(safe-area-inset-bottom));
  background: var(--ink); color: var(--paper-on-ink);
  padding: 11px 18px; font-size: 13px; letter-spacing: 0.06em;
  max-width: min(92vw, 520px); line-height: 1.5; z-index: 60;
}
.toast.err { background: var(--status-error); }

.loading { padding: 40px 16px; text-align: center; color: var(--ink-tertiary); font-size: 13.5px; }

/* =============================================================== PHONE band
   < 760px. Nav becomes a drawer; list and detail become two screens. */

@media screen and (max-width: 759px) {

  #nav {
    position: fixed; inset: 0 auto 0 0; width: min(78vw, 300px); z-index: 50;
    transform: translateX(-100%); transition: transform var(--duration) var(--ease);
    border-right: 1px solid var(--line-strong);
    padding-top: env(safe-area-inset-top);
  }
  body.nav-open #nav { transform: translateX(0); }
  #navscrim {
    display: block; position: fixed; inset: 0; background: rgba(36, 28, 16, 0.45);
    z-index: 40; opacity: 0; pointer-events: none;
    transition: opacity var(--duration) var(--ease);
  }
  body.nav-open #navscrim { opacity: 1; pointer-events: auto; }

  .wrap { padding: 16px 14px 40px; padding-bottom: max(40px, env(safe-area-inset-bottom)); }
  .form { padding: 16px 14px 60px; }

  /* One column, always. The schema's `cols` count is written as an inline
     style by the renderer, so this has to be !important to beat it — and the
     renderer is the right place for that number, because it is a property of
     the field, not of the screen. */
  .fgrid { grid-template-columns: 1fr !important; }
  /* A field's own colspan (e.g. Contact, Notes) is ALSO an inline style, set
     independently of the container. Collapsing the container to one column
     does nothing to a child still asking for `grid-column: span 2` — the grid
     just fabricates a second, empty implicit column to satisfy it, and every
     other field in that section gets redistributed across the two, landing
     on top of each other. Every field is span 1 once there is only one
     column to span. */
  .fgrid > .field { grid-column: span 1 !important; }
  /* ⚠ DELIBERATELY LEFT AS A PAIR — do not "fix" this into one column. The
     label spans, the two money boxes sit SIDE BY SIDE beneath it. Stacked one
     per row, twelve cost lines ran to about 1,700px of scrolling and the pair
     stopped reading as a pair — which is the whole point of it: THB and USD
     are the same figure, and splitting them onto separate rows is what makes
     a reader lose track of which is which. The placeholders say THB and USD,
     so the column headings are not needed. Same reasoning, same shape, for
     `.moneypair` in the pricing block (quotations, invoices, receipts) — it
     has no override here for the same reason. */
  .costgrid { grid-template-columns: 1fr 1fr !important; gap: 2px 8px; }
  .costgrid .ch, .costgrid .costhead { display: none; }
  .costgrid .cl { grid-column: 1 / -1; margin-top: 14px; }
  .costgrid .derivedfig { text-align: left; padding: 8px 0; }

  /* The staff permissions line ("costs + prices · edits orders · …") is
     nowrap + margin-left:auto on desktop, where it reads as a right-aligned
     summary. On a 375px screen that same nowrap just runs the text off the
     edge of the device with nothing to say more of it exists — the same
     failure already fixed once for the redaction caution, recurring here.
     flex-basis:100% drops it onto its own full-width line, where it can wrap
     like the hint text next to it already does. */
  .rowlist .meta { white-space: normal; margin-left: 0; flex-basis: 100%; }

  /* `.tabs` scrolls sideways by design above this breakpoint (see the note on
     the base rule — Settings' 7 sub-nav links do not fit a phone width, and a
     hidden scrollbar with a fade is the considered fallback). Joe's own
     instruction, 14 Sep 2026, is stricter for the phone band specifically:
     no horizontal scroll anywhere, only vertical. Wrapping onto a second row
     instead shows every link at once rather than hinting more exist off the
     right edge — strictly more of the strip visible, never less. */
  .tabs {
    flex-wrap: wrap; overflow-x: hidden;
    mask-image: none; -webkit-mask-image: none;
  }

  /* Two screens, one at a time. */
  .listpane   { width: 100%; border-right: none; }
  body.on-detail .listpane { display: none; }
  body:not(.on-detail) .detailpane { display: none; }
  /* Hidden rather than further truncated. Backbtn + this + Save alone fit; add
     the invoice-only "Quick entry / Full details" toggle (records.js) and the
     four together ran past 375px, dragging `.tabs` into the same horizontal
     scroll that stranded Save off the right edge on a phone — found 14 Sep
     2026 auditing the mobile breakpoint. What it shows (client name, status)
     is already on the page: the big record number heading and the Status
     field sit right below. Scoped to `.tabs` — the record toolbar's only use
     of the class — so nothing else is affected. */
  .tabs .recmeta { display: none; }

  /* Tables restack as labelled cards. data-label is written by the renderer. */
  .ftable, .ftable tbody, .ftable tr, .ftable td { display: block; width: 100%; }
  .ftable thead { display: none; }
  .ftable tr {
    border: 1px solid var(--line-strong); background: var(--paper);
    padding: 10px; margin-bottom: 10px;
  }
  .ftable td { padding: 5px 0; }
  .ftable td::before {
    content: attr(data-label); display: block;
    font-size: 11px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
    color: var(--ink-tertiary); margin-bottom: 3px;
  }
  .ftable td:empty { display: none; }

  /* Buttons that sat side by side (New order / Delete, the Dashboard's row of
     module shortcuts) stack full-width instead. Two half-width buttons on a
     375px screen are each about 170px — tappable, but "DELETE" next to "NEW
     ORDER" at that width reads as one crowded control, not two. Full width,
     one per row, is unambiguous about where a tap lands. */
  .viewhead .actions { width: 100%; margin-left: 0; flex-direction: column; }
  .viewhead .actions .btn { flex: none; width: 100%; }

  /* Dialog buttons (Cancel / confirm, in the stone picker and the crop
     editor) — same reasoning, same fix. Column, not reversed: tab order and
     visual order should agree, and Cancel first, primary action last (nearest
     the thumb) is the ordinary mobile convention. */
  .modalacts { flex-direction: column; }
  .modalacts .btn { width: 100%; }

  /* The crop dialog's zoom slider and rotate/reset controls, one per line
     rather than wrapped into a crowded toolbar row. */
  .croptools { flex-direction: column; align-items: stretch; gap: 8px; }
  .croptools .btn, .croptools input[type="range"] { width: 100%; }

  /* ---- the top bar on a phone ----------------------------------------
     The company line wrapped to three lines and squeezed the view name down
     to "ORDE…". On a 375px screen the company name is not information — the
     person holding the phone knows whose app it is. What they need is which
     screen they are on, in full. */
  #topbar { gap: 10px; padding: 8px 12px; padding-top: max(8px, env(safe-area-inset-top)); }
  #topbar .overline { display: none; }
  #topbar .appname { font-size: 14px; margin-top: 0; }
  /* Role reduced to the role itself. "This machine · owner" is not worth the
     width on a 375px screen; "owner" is the part that changes what you see. */
  #whoami { font-size: 11px; padding: 4px 7px; font-size: 0; }
  #whoami::after { content: attr(data-role); font-size: 11px; }

  /* Micro-labels get a HIGHER floor again on touch screens — 11.5px against
     the base 11px. Still a floor and never a cap: the rule at the top of this
     file (no intermediate query may be tighter than the base) applies to type
     as much as to width, and a phone override that made text SMALLER than the
     desktop rule would be exactly that mistake. */
  .field label, .r-sub, .tile .k, .tot .k { font-size: 11.5px; }
  .overline, #nav .navgroup { font-size: 11.5px; }
}

/* ============================================================== TABLET band
   760–1099px. Nav is a horizontal row above the content. */

@media screen and (min-width: 760px) and (max-width: 1099px) {
  #shell { flex-direction: column; }
  #nav {
    flex-direction: row; border-right: none; border-bottom: 1px solid var(--line-strong);
    overflow-x: auto; overflow-y: hidden; scrollbar-width: none;
    /* Nine-plus modules don't reliably fit this band, and the scrollbar is
       hidden on both engines above — with nothing else on screen, a module
       past the fold is not just unreachable-looking, it's invisible. A
       standing fade at the trailing edge is a cheap, permanent "there is
       more here" cue; it costs nothing when the row does happen to fit,
       since it only ever touches the last few pixels. */
    mask-image: linear-gradient(to right, black calc(100% - 28px), transparent);
    -webkit-mask-image: linear-gradient(to right, black calc(100% - 28px), transparent);
  }
  #nav::-webkit-scrollbar { display: none; }
  #nav .navgroup, #navfoot { display: none; }
  #nav a {
    border-left: none; border-bottom: 3px solid transparent;
    padding: 12px 16px; white-space: nowrap;
  }
  #nav a.on { border-left-color: transparent; border-bottom-color: var(--accent-bronze); }
  #navtoggle { display: none; }
  .listpane { width: 300px; }
}

/* ============================================================= DESKTOP band
   >= 1100px. Nav is a permanent left rail. */

@media screen and (min-width: 1100px) {
  #nav { width: 220px; }
  #navtoggle { display: none; }
  .wrap { padding: 26px 28px 56px; }
  .form { padding: 26px 28px 70px; }
  #topbar { padding: 12px 22px; }
  .listpane { width: 360px; }
}

/* Very wide. Nothing stretches; the rail just gets a little more room and the
   content stays inside --container-max, which is set in the base rule. */
@media screen and (min-width: 1800px) {
  #nav { width: 250px; }
  .listpane { width: 400px; }
}

/* ==================================================================== print

   The application chrome is hidden outright, so nothing on screen can leak on
   to a printed sheet by accident. Status colour in particular is structurally
   incapable of reaching paper. */
/* ==========================================================================
   THE PERMISSION TICK GRID
   ========================================================================== */

.permgrid { margin-top: 14px; border-top: 1px solid var(--line); }

.permgroup {
  font-family: var(--font-display); font-size: 12px; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-tertiary);
  margin: 16px 0 6px; padding-bottom: 4px; border-bottom: 1px solid var(--line);
}

/* The whole row is the label, so the note is part of the click target. A tick
   with a reason beside it that you cannot click is a reason nobody reads. */
.permrow {
  display: grid; grid-template-columns: 22px 1fr; gap: 4px 8px;
  padding: 9px 0; cursor: pointer; align-items: start;
  border-bottom: 1px solid var(--line);
}
.permrow:last-child { border-bottom: 0; }
.permrow:hover .permlabel { color: var(--ink); }
.permrow input { width: 17px; height: 17px; margin-top: 1px; cursor: pointer; }

.permlabel { display: block; font-size: 15px; font-weight: 500; color: var(--ink); }
.permneeds {
  font-size: 12px; color: var(--ink-tertiary); font-style: normal;
}
/* The reason, not just the name. This is the part that makes somebody hesitate
   over "see costs and margins", which is the whole point of showing it. */
.permnote {
  display: block; font-size: 13px; line-height: 1.5;
  color: var(--ink-secondary); margin-top: 2px;
}

.modalbox.wide { width: min(100%, 620px); }

@media screen and (max-width: 759px) {
  .permrow { grid-template-columns: 26px 1fr; }
  /* Never below 16px on a phone or Safari zooms the page and never comes back. */
  .permrow input { width: 20px; height: 20px; }
}

/* ==========================================================================
   PHOTOS — tiles, the upload row, and the crop dialog.
   ========================================================================== */

.thumbframe {
  position: relative;   /* the main photo's tile draws its crop — applyFocus */
  border: 1px solid var(--line-strong); background: var(--paper-sunk);
  border-radius: var(--radius-lg); corner-shape: squircle;
  aspect-ratio: 4 / 3; overflow: hidden; display: block;
}
.thumbframe img { width: 100%; height: 100%; object-fit: contain; display: block; }

/* A photo that fails to load (most often an iCloud placeholder that got
   evicted — see CLAUDE.md) falls back to the browser's own broken-image icon
   plus its `alt` text, jammed together with no padding of their own, because
   that pairing is not a box this stylesheet drew. `safeImg()` in ui.js adds
   this class on error; every frame it can appear in already has its own
   border and fill colour, so hiding the broken image leaves a clean, honest
   empty frame instead of icon-and-text wreckage. */
/* !important because every context this can appear in (`.thumbframe img`,
   `.r-thumb img`, `.imgframe img`, `.crop45 img`) already carries its own
   sizing rule at a HIGHER specificity than a single class selector — without
   it, "hide the broken image" quietly loses to "this image is 100% wide" and
   the icon-and-text mess is back. */
img.img-broken { display: none !important; }

/* The photo that prints half-page on page 1. Marked with the ink rule rather
   than a colour, so it reads the same to anyone. */
.tile.isprimary { outline: 2px solid var(--ink); outline-offset: 2px; }

.tileacts {
  display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 6px;
}
.tileacts .fine { display: flex; align-items: center; gap: 5px; }

/* A destructive-sounding action that is not destructive — the file stays on
   disk. Set as a link, not a red button, because the weight should match what
   actually happens. */
.linkbtn {
  appearance: none; background: none; border: 0; padding: 0; border-radius: 0;
  font-family: var(--font-text); font-size: 14px; color: var(--ink-tertiary);
  text-decoration: underline; text-underline-offset: 2px; cursor: pointer;
  /* ⚠ IT LOOKS LIKE A LINK AND IS TAPPED LIKE A BUTTON. The text is 19px tall,
     under the 24px minimum, and there are three of these now — Remove on a
     photo, Unlink on a stone card, Unlink on the client strip. Padding would
     push the underline off the words it belongs to, so the target is grown
     instead of the mark, exactly as `.helpmark` does it. Relative, or the
     ::after has nothing to be inset from. */
  position: relative; display: inline-block;
}
.linkbtn::after { content: ''; position: absolute; inset: -5px -6px; }
.linkbtn:hover { color: var(--status-error); }

/* Two EQUAL-WIDTH columns, always — not auto-fit sized off content. Joe's
   instruction, 4 Sep 2026: "all buttons must be equal sized." With four
   categories this is a clean 2x2 block at every width, phone included, and a
   fixed column count is what actually guarantees equal WIDTH — auto-fit sizes
   each column from its own content, which is exactly what made "Client
   References" and "CAD Renders" different widths before. */
.uploadrow {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 14px; margin-top: 16px;
}

/* ------------------------------------------------------------- the cropper */

.modal {
  position: fixed; inset: 0; z-index: 95; padding: 20px;
  background: rgba(36, 28, 16, 0.55);
  display: flex; align-items: center; justify-content: center;
}
/* ⚠ FLEX COLUMN, NOT GRID — found on the crop dialog, on a phone. `display:
   grid` with implicit auto rows sizes each row from an early layout pass;
   `.crop45` right below resolves its own height LATER, from `aspect-ratio`
   plus a width that itself comes from the grid column — a circular
   width-from-column / height-from-aspect-ratio dependency grid does not
   always re-settle. The row grid committed to was shorter than the box that
   landed in it, and the zoom slider one row down rendered 145px INSIDE the
   photo frame — behind it, so the control was there but nothing on screen
   showed it or could reach it. A flexbox column lays out each child in
   document order with no separate "size every row first" pass, so the box
   that comes back from aspect-ratio is simply this item's real size, not a
   forecast something later has to match. */
.modalbox {
  background: var(--paper); border: 1px solid var(--ink);
  border-radius: var(--radius-lg); corner-shape: squircle;
  padding: 22px; width: min(100%, 420px); max-height: 92vh; overflow: auto;
  display: flex; flex-direction: column; gap: 12px;
}
/* The frame is 4:5 because that is the shape it prints at on page 1. Dragging
   inside a frame that is not the printed shape teaches the wrong crop. */
.modalbox .crop45 { width: 100%; flex: none; touch-action: none; cursor: grab; user-select: none; }
.modalbox .crop45:active { cursor: grabbing; }
/* The frame takes keyboard focus (arrow keys pan, +/− zoom). The ring sits
   OUTSIDE the photo — the global inset ink ring would vanish against a dark
   stone. */
.modalbox .crop45:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
/* The keyboard hint, only where there is a real pointer and so, almost
   always, a keyboard. */
.kbdhint { display: none; }
@media (hover: hover) and (pointer: fine) { .kbdhint { display: block; } }
/* Belt and braces with the `draggable="false"` on the img itself (form.js) —
   this is what actually stops Safari, which still starts a native image drag
   in some cases even with the attribute set. */
.modalbox .crop45 img { -webkit-user-drag: none; user-select: none; }

.croptools { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.croptools input[type="range"] { flex: 1 1 90px; min-width: 90px; }
.modalacts { display: flex; gap: 10px; justify-content: flex-end; }

/* ------------------------------------------- estimate from design (quotation)
   views/piece-estimate.js — 26 Sep 2026. Wider than a plain dialog: a photo
   row, two short form grids and the worked-out sum. One column on a phone. */
.modalbox.estimate { width: min(100%, 720px); }
.est-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media screen and (max-width: 759px) { .est-grid { grid-template-columns: minmax(0, 1fr); } }
.est-photos { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.est-photo { display: flex; flex-direction: column; gap: 6px; width: 120px; font-size: 12px;
             overflow-wrap: anywhere; cursor: pointer; }
.est-photo img, .est-photo-new {
  width: 120px; height: 120px; object-fit: cover; display: grid; place-items: center;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg); corner-shape: squircle;
}
.est-ai { display: flex; flex-direction: column; gap: 6px; }
.est-ai ul { margin: 0; padding-left: 18px; }
.est-sum { display: flex; flex-direction: column; gap: 6px; }
.est-price { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
.est-price strong { font-size: 22px; font-variant-numeric: tabular-nums; }
.est-gap { color: var(--status-error); }
/* Quick estimate page (views/quick-estimate.js): the one big figure. */
.est-total { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line-strong); }
.est-grand { font-size: 30px; font-variant-numeric: tabular-nums; color: var(--ink); margin-top: 4px; }
/* Settings → Pricing multipliers: metal name, five tiers, density. On a
   phone the name takes its own line and the six boxes share the row below —
   in a seven-column table they shrank until the numbers could not be read. */
.mgrid { display: grid; gap: 8px; align-items: center; margin-top: 12px;
         grid-template-columns: minmax(96px, 1.4fr) repeat(5, minmax(0, 1fr)) minmax(0, 1.2fr); }
.mgrid .mh { font-size: 11px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase;
             color: var(--ink-tertiary); text-align: center; }
.mgrid .mname { font-size: 14px; color: var(--ink); }
.mgrid input {
  width: 100%; min-width: 0; padding: 8px 2px; text-align: center;
  font-size: 16px; font-variant-numeric: tabular-nums; color: var(--ink); background: var(--paper);
  border: 1px solid var(--line-field); border-radius: var(--radius-lg); corner-shape: squircle;
}
.tierrow { display: grid; gap: 8px; align-items: start; margin-top: 10px;
           grid-template-columns: 40px minmax(0, 1fr) minmax(0, 2fr) minmax(0, 2fr); }
.tierrow .overline { padding-top: 10px; }
.tierrow input, .tierrow textarea {
  width: 100%; min-width: 0; padding: 9px 10px; font-size: 16px; color: var(--ink);
  background: var(--paper); border: 1px solid var(--line-field);
  border-radius: var(--radius-lg); corner-shape: squircle; font-family: inherit;
}
@media screen and (max-width: 759px) {
  .mgrid { grid-template-columns: repeat(5, minmax(0, 1fr)) minmax(0, 1.3fr); gap: 6px; }
  .mgrid .mh-blank { display: none; }
  .mgrid .mname { grid-column: 1 / -1; margin-top: 8px; font-weight: 500; }
  .tierrow { grid-template-columns: minmax(0, 1fr); gap: 6px; margin-top: 16px; }
  .tierrow .overline { padding-top: 0; }
}
/* Settings → Pricing tables (views/settings-pricing.js). */
.ptable { border-collapse: collapse; width: 100%; margin-top: 10px; font-size: 13px;
          font-variant-numeric: tabular-nums; }
.ptable th { text-align: left; font-weight: 500; color: var(--ink-soft, var(--ink)); padding: 6px 8px;
             border-bottom: 1px solid var(--line-strong); white-space: nowrap; }
.ptable td { padding: 6px 8px; border-bottom: 1px solid var(--line); }
.ptable td input { border: 1px solid var(--line-field); padding: 6px 8px; min-width: 56px;
                   border-radius: var(--radius-lg); corner-shape: squircle; }

/* ------------------------------------------------------- the stone picker */

.picklist {
  max-height: 46vh; overflow-y: auto; border: 1px solid var(--line);
  display: flex; flex-direction: column;
}
.pickrow {
  appearance: none; background: none; border: 0; border-bottom: 1px solid var(--line);
  padding: 10px 12px; text-align: left; cursor: pointer; font-family: var(--font-text);
}
.pickrow:last-child { border-bottom: 0; }
.pickrow:hover { background: var(--paper-sunk); }
.pickrow-main { font-size: 14.5px; color: var(--ink); }
.pickrow-sub  { font-size: 12px; color: var(--ink-tertiary); margin-top: 2px; }

/* The client typeahead (engine/clientpick.js) — the picker's own rows, in
   flow under the name box rather than in a modal, so on a phone the list
   pushes the page down instead of hiding behind the keyboard. */
.picklist.suggest { max-height: 280px; margin-top: 6px; background: var(--paper); }
.picklist.suggest[hidden] { display: none; }
.picklist.suggest .pickrow.active { background: var(--paper-sunk); }

@media screen and (max-width: 759px) {
  .modal { padding: 12px; align-items: flex-start; }
  .modalbox { padding: 16px; }
  /* Even fixed, a 4:5 frame at the full width of a phone screen is tall
     enough that the zoom slider and both rotate buttons only fit by
     scrolling the dialog first — reachable, but not obviously so on the
     one control surface in the app meant to be dragged with a thumb.
     Capped by height with the width now following FROM it (not the other
     way round, as on desktop), so cropping still shows enough of the photo
     to judge the crop by, with the rest of the dialog in view beneath it
     without hunting for a scrollbar. */
  .modalbox .crop45 { width: auto; max-width: 100%; height: min(38vh, 320px); margin-inline: auto; }
}

/* ==========================================================================
   THE PRINTED SHEET — screen side.

   The sheet itself is styled in sheet.css, which is the A4 geometry and is
   deliberately a separate file: it is the part that must not change when the
   app's chrome does. What follows is only how the preview sits on screen.
   ========================================================================== */

/* Edit / Master copy / Workshop copy. Which copy you are looking at is the
   single most consequential thing on this screen — the workshop copy is the
   one that leaves the building — so it is a visible choice, never a mode you
   can be in without noticing. */
/* padding matches .tabs (the back-button/Save row directly above this one)
   exactly — without it the strip's own box sat flush against the pane
   edge while every row stacked around it carried a visible inset. The
   border-bottom still spans edge to edge; padding never clips a border. */
.sheettabs { display: flex; gap: 0; padding: 0 12px; border-bottom: 1px solid var(--line-strong); }
.sheettabs button {
  appearance: none; background: none; border: 0; border-bottom: 2px solid transparent;
  padding: 10px 14px; font-family: var(--font-text); font-size: 14px; font-weight: 500;
  color: var(--ink-tertiary); cursor: pointer; border-radius: 0;
}
.sheettabs button:hover { color: var(--ink); }
.sheettabs button.on { color: var(--ink); border-bottom-color: var(--ink); }
/* The master copy carries costs and the client's name. The tab says so. */
.sheettabs button.on[data-copy="master"] { border-bottom-color: var(--status-error); }

.sheetpane { padding: 18px 0 40px; background: var(--paper-sunk); }

/* --- true-size A4, scaled to fit — Joe, 17 Sep 2026 -------------------------
   `#preview` now holds real 210mm×297mm sheets (sheet.css) at every screen
   width — a phone included, pinch to zoom — rather than a narrower or
   differently-laid-out page below 759px. `.sheetscale` is the frame that
   shrinks them to fit: `#preview` carries a CSS `transform: scale()` (the
   `--page-scale` custom property, computed by `updateScale()` in
   sheetpane.js from this wrapper's own width) rather than being resized
   itself, because a transform is the only way to shrink a page's PAINT
   without also reflowing its actual A4 geometry — which is the entire point,
   since that geometry is what predicts the printer now (see paginate.js).

   `overflow: hidden` is a safety margin only — `updateScale()` sets this
   wrapper's OWN height to the scaled page stack's real height every time, so
   there should be no dead space to clip in the first place; this only
   guards the frame after a scale/repagination and before the next
   `updateScale()` catches up.

   ⚠ `display: flex; justify-content: center` — NOT `margin: 0 auto` on
   `#preview`. Found by actually measuring a phone screenshot: below 375px
   `#preview`'s unscaled width (794px, from `width: 210mm`) is wider than its
   container, and a block element's `auto` margins resolve to ZERO once it no
   longer fits — CSS 2.1 §10.3.3 — rather than centring the overflow, so the
   page sat flush against the LEFT edge before any transform ran at all.
   `transform-origin: top center` then measured "centre" from THAT flush-left
   box, landing the scaled page 209px right of where it needed to be — most
   of it off the right edge of a 375px phone, with a matching gap on the
   left. Flexbox's `justify-content: center` centres an overflowing item
   SYMMETRICALLY, which block auto-margins do not; with the box actually
   centred first, `transform-origin: center` (the item's own centre) lines
   up with the container's centre too, and the scaled result exactly fills
   the width it was computed for. */
.sheetscale { width: 100%; overflow: hidden; background: var(--paper-sunk); display: flex; justify-content: center; }
#preview {
  width: 210mm;
  transform: scale(var(--page-scale, 1)); transform-origin: top center;
}

/* Showing a copy HIDES the form rather than sitting beside it. Two reasons,
   and the second is the real one: an A4 page needs the width, and a form and a
   printed sheet of the same record on screen at once is an invitation to edit
   the one you are not looking at. */
body.on-sheet .form { display: none; }
.sheettools {
  display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
  max-width: 182mm; margin: 0 auto 14px; padding: 0 4px;
}
.sheettools .spacer { flex: 1 1 auto; }
.sheetnote { font-size: 13px; color: var(--ink-tertiary); line-height: 1.5; }

/* The redaction check's verdict. Sits ABOVE the sheet, not beside it — it is
   about the pages below it and has to be read before they are printed. */
#checkresult {
  max-width: 182mm; margin: 0 auto 16px; padding: 12px 14px;
  font-size: 14px; line-height: 1.6; background: var(--paper);
  border: 1px solid var(--ink);
}
#checkresult .verdict {
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.18em;
  text-transform: uppercase; margin-bottom: 4px;
}
#checkresult.fail { border-color: var(--status-error); color: var(--status-error); }
#checkresult.pass { border-color: var(--status-success); color: var(--status-success); }
#checkresult.warn { border-color: var(--ink); color: var(--ink); }
#checkresult div + div { margin-top: 4px; }

/* ⚠ `.printonly` used to mean "hide this off a phone — A4 is not a phone
   workflow" and was hidden below 760px on that reasoning. It no longer holds:
   the button is now labelled Export, not Print (see sheetpane.js), and saving
   a PDF from a showroom phone to email a client IS a workflow — arguably more
   so than printing paper ever was there. The class name is kept (it still
   marks "only relevant when producing a document," which is true on desktop
   and phone alike) but the phone-hide rule under it is gone. */
@media screen and (max-width: 759px) {
  .sheetpane { padding: 12px 0 28px; }
}

/* ========================================================= portrait only

   Joe's call, 4 Sep 2026: the phone app is portrait. The manifest and
   `screen.orientation.lock()` (see main.js) turn the screen back where the
   platform allows it; iOS allows neither, so this is the mechanism that
   actually holds there.

   THE THREE CONDITIONS TOGETHER ARE THE POINT — none of them is safe alone:
     (hover: none) and (pointer: coarse)  a touch device, so a laptop is never
                                          caught, touchscreen ones included
     (orientation: landscape)             held sideways
     (max-height: 520px)                  a PHONE sideways. An iPad in
                                          landscape is 768px+ tall and carries
                                          on working, which is right — the
                                          two-column form fits there.
   Drop any one of these and somebody with a short browser window on a desktop
   gets told to rotate a device that does not turn. */
#rotate { display: none; }

@media (hover: none) and (pointer: coarse) and (orientation: landscape) and (max-height: 520px) {
  #rotate {
    display: flex; position: fixed; inset: 0; z-index: 300;
    align-items: center; justify-content: center;
    padding: 20px calc(20px + env(safe-area-inset-right)) 20px calc(20px + env(safe-area-inset-left));
    background: var(--paper);
  }
  .rotatebox { max-width: 46ch; text-align: center; }
  .rotatebox .mark { width: 34px; height: auto; opacity: 0.75; margin-bottom: 12px; }
  .rotatebox .display { font-size: 15px; margin-bottom: 9px; }
  .rotatebox p:last-child {
    font-size: 14px; line-height: 1.65; color: var(--ink-secondary);
  }
  /* The app is still there and still holds everything typed — it is covered,
     never unmounted — but it must not scroll behind the panel. */
  #app { overflow: hidden; }
}

@media print {
  /* Everything that is not the sheet. The FORM is hidden here rather than
     merely covered, which is what makes screen-only material — the 90 lines of
     plain-English help, the status colour — structurally incapable of reaching
     paper. Do not soften this to a visibility or an opacity. */
  #topbar, #crumbbar, #nav, #navscrim, .tabs, .filters, .listpane, .toast,
  .form, .sheettabs, .sheettools, #checkresult,
  /* `.skiplink` (index.html) is the WCAG bypass-blocks link. It sits off-screen
     at `top: -100px` on screen, but that is still `position: fixed`, and a
     fixed element prints wherever the browser lands it rather than staying
     off the page — it showed up faintly at the top of printed/PDF output.
     Hidden here, screen behaviour untouched: it still appears on keyboard
     focus (`.skiplink:focus` above), since focus-visibility is a screen-only
     concern and nobody can print while mid-tab. */
  .skiplink,
  /* `.tip` is the field help, and it is a child of <body> rather than of the
     form — so hiding the form no longer hides it. Named here as the second
     half of the screen-only guarantee (tooltip.js closes it on beforeprint as
     the first). Do not remove either one. */
  .tip { display: none !important; }
  body, #app, #shell, #main, .view, .detailpane, .formwrap,
  .sheetpane, .sheetscale, #preview { display: block !important; overflow: visible !important; }
  /* `position: fixed` — now body's normal state, for the iOS PWA bottom-gap
     fix above — only ever prints on page one; any fixed element is excluded
     from print's page-flow entirely, so a multi-page order sheet would have
     lost every page after the first. Screen-only, reset back to normal flow
     here. */
  body { position: static; inset: auto; }
  html.fitscreen body { height: auto; }   /* the measured screen height is screen-only too */
  html, body, .sheetpane, .sheetscale { background: #fff; padding: 0; }
  /* Explicit 182mm, matching `@page`'s printable width (A4 minus sheet.css's
     14mm margin on each side) and `#preview .page`'s own print width
     (sheet.css) — not 210mm, the full sheet width, which left only 182mm of
     printable area for a 210mm-wide ancestor and made Chrome shrink-to-fit
     the whole print at ~0.87. See HISTORY.md, 17 Sep 2026.
     `transform: none` still undoes the screen-only shrink-to-fit. */
  .sheetscale { width: 182mm; height: auto !important; }
  #preview { width: 182mm; margin: 0; transform: none; background: #fff; }
  /* iOS/iPadOS Safari's print pipeline treats A4 as 595×842 CSS px (points,
     not 96dpi) minus its own ~0.5in margins — a ~523×770px printable area,
     too small for our 182mm×269mm sheet. `zoom` (not `transform`, which
     print ignores here) shrinks the whole stack to fit; untouched on macOS
     Safari and Chrome/Android, which lay out A4 correctly already.

     ⚠ 0.72 WAS CALIBRATED AGAINST A 210mm-WIDE `#preview` — the 17 Sep 2026
     fix directly above narrowed it to 182mm and left this number untouched,
     so an invoice printed from an iPhone since then comes out at roughly 70%
     of the page instead of fitting it. Set to 1 (i.e. this rule now does
     nothing) — CONFIRMED CORRECT on a real on-device test, not just a
     desktop or simulator print preview (neither runs this code path).
     Verified 21 Sep 2026: Joe printed a live invoice (RIN-260921-002) to PDF
     from iOS Safari on his iPhone. Content spanned ~175mm of the 182mm
     printable width (up from ~127mm before this fix) and ~271mm against the
     283mm printable floor, both pages correct, signature block correctly
     bottom-pinned, no overflow and no shrink-to-fit. The 0.95 fallback this
     comment used to name was not needed and should not be reintroduced.
     Left as a live rule rather than deleted, because the iOS print quirk it
     exists for has not gone away — only the number needed fixing. */
  @supports (-webkit-touch-callout: none) {
    #preview { zoom: 1; }
  }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important; transition-duration: 0.01ms !important;
  }
}

/* --- document lineage -------------------------------------------------------
   What this record came from, what it became, and what it may become next.
   Above the form, because "this invoice came from RQT-260823-001" is context
   you need before reading a figure, not after. */
.lineage {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px;
  padding: 10px 0 14px; margin-bottom: 6px;
  border-bottom: 1px solid var(--line);
}
.lineage-row { display: flex; align-items: baseline; gap: 8px; font-size: 15px; }
.lineage-k {
  font-size: 11.5px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ink-tertiary);
}
.lineage a { color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--line-strong); }
.lineage a:hover { border-bottom-color: var(--ink); }

/* ⚠ `.btn` is `white-space: nowrap`, which is right for "Save" and wrong for
   "Raise an order sheet from this quotation" — measured at 320px, that button
   is 326px wide inside a 292px row that has no scroller, so its last words ran
   off the right edge and were simply gone. Found in the 4 Sep layout sweep;
   it predates the tooltip work. It wraps on a phone instead. Left-aligned
   because a centred second line under a first one reads as two buttons. */
@media screen and (max-width: 759px) {
  .lineage .btn {
    white-space: normal; text-align: left; justify-content: flex-start;
    line-height: 1.35; max-width: 100%;
  }
}

/* ==================================================== AI Assistant (12 Sep 2026)
   One page, not woven into any record screen — Joe's call. Search and bill
   reading share this one thread; which happens is decided by whether a
   message carries an attachment, not by a mode switch. See views/assistant.js.

   ⚠ THE ONE PLACE IN THIS APP WITH CORNER RADIUS, BY DELIBERATE, SCOPED
   EXCEPTION — Joe's call, 13 Sep 2026: "make it look like an actual AI chat
   session... keep it similar to our Rida branding." Every other screen holds
   `tokens.css`'s zero-radius, no-shadow rule exactly; a chat thread read as
   wrong with hard square bubbles, the same way the rest of the app would
   read as wrong with rounded ones. So corners round HERE ONLY — scoped to
   `.chat-*` classes and nowhere else — while everything that makes this app
   itself (ink/paper tokens, Figtree, no accent colour, no shadow) stays
   exactly as it is everywhere else. `--chat-radius*` are local to this
   block on purpose, not added to tokens.css, so nothing outside this page
   can inherit them by accident. */
:root {
  --chat-radius-lg: 22px;
  --chat-radius-md: 16px;
  --chat-radius-sm: 10px;
  /* Pinned to HALF THE RESTING COMPOSER HEIGHT (38px button + 5px
     top/bottom bar padding + 1px+1px border = 50px, so 25px) — see the
     comment on .chat-composer below for why this is a flat value rather
     than derived from --chat-radius-lg or an auto-scaling border-radius. */
  --chat-composer-radius: 25px;
}

.chat-panel {
  display: flex; flex-direction: column; height: min(70vh, 640px);
  border: 1px solid var(--line); background: var(--paper);
  border-radius: var(--chat-radius-lg); corner-shape: squircle; overflow: hidden;
}
.chat-thread {
  flex: 1; min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column; gap: 16px;
  padding: 20px;
}
/* The empty-state — replaces the old paragraph of instructions with two
   concrete starting points. `hidden` (a real DOM property, not a class) the
   moment the first message lands, and never shown again for this page load. */
.chat-empty { margin: auto; padding: 24px 12px; text-align: center; max-width: 380px; }
.chat-empty-title { font-size: 15px; color: var(--ink-secondary); margin-bottom: 14px; }
.chat-suggestions { display: flex; flex-direction: column; gap: 8px; }
.chat-suggestion {
  display: flex; align-items: center; gap: 10px; padding: 11px 16px;
  border: 1px solid var(--line-strong); border-radius: var(--chat-radius-md); corner-shape: squircle;
  background: var(--paper); color: var(--ink); font: inherit; font-size: 14.5px;
  cursor: pointer; text-align: left;
}
.chat-suggestion:hover { background: var(--paper-sunk); }
.chat-suggestion .icon { flex: none; color: var(--ink-tertiary); }
.chat-empty .fine { margin-top: 14px; }

.chat-msg { display: flex; }
.chat-msg.chat-user { justify-content: flex-end; }
.chat-msg.chat-assistant { justify-content: flex-start; }
.chat-msg > div { max-width: min(520px, 88%); display: flex; flex-direction: column; gap: 8px; }

/* Uniform full radius, not a tailed speech bubble — a message can hold more
   than one block stacked (a reply, a fields table, a warnings list, a
   button), and flattening one corner per bubble reads as a bug the moment
   two are stacked instead of looking deliberate. */
.chat-bubble {
  border: none; border-radius: var(--chat-radius-md); corner-shape: squircle;
  padding: 11px 16px; font-size: 14.5px;
  line-height: 1.6; color: var(--ink); background: var(--paper-sunk);
  white-space: pre-wrap; overflow-wrap: anywhere;
}
.chat-msg.chat-user .chat-bubble { background: var(--ink); color: var(--paper-on-ink); }
.chat-bubble.muted { background: transparent; border: 1px dashed var(--line-strong); color: var(--ink-tertiary); font-style: italic; }
.chat-bubble.err { background: var(--paper); border: 1px solid var(--status-error); color: var(--status-error); }
.chat-bubble a { color: inherit; text-decoration: underline; }
.chat-thumbs { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.chat-thumbs img {
  width: 72px; height: 72px; object-fit: cover; border-radius: var(--chat-radius-sm);
  corner-shape: squircle;
}

/* A search match — the same row shape `.records li.has-thumb.compact` already
   draws, as an anchor instead of a button so it is a normal link (open in a
   new tab, etc.) as well as a click. */
.chat-cards { display: flex; flex-direction: column; gap: 8px; }
.chat-card {
  display: flex; align-items: center; gap: 12px; padding: 8px 10px;
  border: 1px solid var(--line); background: var(--paper); border-radius: var(--chat-radius-sm);
  corner-shape: squircle;
  text-decoration: none; color: inherit;
}
.chat-card:hover { background: var(--paper-sunk); }
.chat-card-img {
  width: 44px; height: 44px; flex: none; object-fit: cover; border-radius: 8px;
  corner-shape: squircle;
  border: 1px solid var(--line-strong); background: var(--paper-sunk);
}
.chat-card-img.empty { display: flex; align-items: center; justify-content: center; color: var(--line-strong); }

/* The extracted-fields preview on a bill reading — a plain key/value list,
   not a form: this is a summary to check, not something to edit in place.
   Corrections happen on the record itself after "Add to inventory". */
.chat-fields {
  display: grid; grid-template-columns: auto 1fr; gap: 4px 12px;
  border: 1px solid var(--line); padding: 12px 15px; background: var(--paper-sunk);
  border-radius: var(--chat-radius-sm); corner-shape: squircle;
  /* .k and .v differ in font-size, so their line-boxes differ in height too
     (line-height is unitless, so it scales with font-size) — without this,
     each row's shorter box (.k, the label) top-aligns by grid default and
     sits visibly higher than its own value beside it. */
  align-items: center;
}
.chat-field { display: contents; }
.chat-field .k {
  font-size: 11px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--ink-tertiary); white-space: nowrap;
}
.chat-field .v { font-size: 14px; color: var(--ink); overflow-wrap: anywhere; }

/* Things the model was unsure about — a judgement call, a second figure on
   the bill, a photo that didn't look like a bill at all. Not an error: a
   left rule in muted ink, not the status-error colour, because nothing has
   failed — it is a prompt to look closer before trusting the field above it. */
.chat-warnings {
  list-style: none; margin: 0; padding: 8px 0 0 12px; border-left: 2px solid var(--ink-muted);
  display: flex; flex-direction: column; gap: 4px;
}
.chat-warnings li { font-size: 13px; color: var(--ink-secondary); line-height: 1.5; }

/* ── the composer: chips + input bar as one floating rounded unit ───────── */
/* --chat-composer-radius (in the :root block above) is a flat px value,
   not border-radius:9999px — so the pill's rounded ends are true
   semicircles concentric with the 38px attach/send buttons at rest, but a
   multi-line message growing the bar taller does NOT grow this radius to
   match; the ends stay pinned and the sides between them just get longer,
   the same way a normal rounded rectangle elongates. */
.chat-composer {
  margin: 0 16px 16px; border: 1px solid var(--line-field); background: var(--paper);
  border-radius: var(--chat-composer-radius);
}
.chat-chips { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 16px 0; }
/* `[hidden]` and `.chat-chips` are both author-origin selectors of equal
   (0,1,0) specificity, so without this the class's own `display: flex`
   silently wins over the UA `[hidden]{display:none}` and the attribute
   does nothing — assistant.js's `chips.hidden = true` left a 12px-tall
   empty flex row sitting in the composer at all times, quietly inflating
   its height even with nothing attached. This rule, declared after, wins
   the tie and actually hides it. */
.chat-chips[hidden] { display: none; }
.chat-chip {
  display: flex; align-items: center; gap: 6px; border: 1px solid var(--line-strong);
  padding: 3px 4px 3px 3px; font-size: 12.5px; color: var(--ink-secondary);
  border-radius: 999px; background: var(--paper-sunk);
}
.chat-chip img { width: 22px; height: 22px; object-fit: cover; border-radius: 50%; }
/* The icon itself is 12px, but the padding is what you actually tap — a
   flush 12x12 target (what this was before) is the exact "no breathing
   room" pattern this whole audit was looking for, and well under any
   reasonable touch-target floor. Negative margin keeps the chip's own
   visual footprint from growing along with the hit area. */
.chat-chip button {
  border: 0; background: none; padding: 6px; margin: -3px -2px -3px 0; cursor: pointer;
  color: var(--ink-tertiary); display: flex; border-radius: 50%;
}
.chat-chip button:hover { color: var(--ink); background: var(--paper); }

/* Vertical padding is 5px, not the 8px other .chat-* bars use — chosen so
   the resting bar height (38px button + 5px top + 5px bottom = 48px, +
   the composer's own 1px+1px border = 50px) lands exactly on
   --chat-composer-radius × 2, which is what makes the pill's ends real
   semicircles rather than merely rounded. */
.chat-inputbar { display: flex; align-items: flex-end; gap: 6px; padding: 5px 8px; }
.chat-input {
  /* 7px, not 9px: at this line-height (24px) that's what puts the
     textarea's own single-line resting height at 38px — the same as the
     attach/send buttons beside it, so neither one forces the bar taller
     than the other and .chat-inputbar's flex-end alignment lines them up
     flush. Growth past one line is still autosize() on input, unaffected
     by this — it measures real scrollHeight, not this resting value. */
  flex: 1; resize: none; border: none; padding: 7px 6px;
  font: inherit; line-height: 1.5; color: var(--ink); background: transparent;
  /* Never below 16px — the one input in the app that was, in defiance of
     this file's own opening rule: Safari zooms the whole page on focus
     below that, and it never zooms back out. */
  font-size: 16px;
  /* One line by default (line-height + this padding), growing only as
     assistant.js's autosize() measures real content — never pre-sized tall
     for a placeholder that happens to be long. */
  min-height: 20px; max-height: 160px; overflow-y: auto;
}
.chat-input:focus { outline: none; }
/* The bar itself carries the focus ring, not the borderless textarea inside
   it — a chat composer reads as one control, so the highlight should land
   on the whole pill, the same way Claude's and ChatGPT's own input boxes
   glow as a unit rather than ruling a line under just the text. */
.chat-composer:focus-within { outline: 2px solid var(--ink); outline-offset: -1px; }

/* Round icon buttons — attach and send. Scoped to `.chat-inputbar` rather
   than redefining `.btn`, so nothing else in the app inherits a circular
   button by accident. */
.chat-iconbtn {
  width: 38px; height: 38px; flex: none; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--line-strong); background: var(--paper);
  color: var(--ink-secondary); cursor: pointer; padding: 0;
}
.chat-iconbtn:hover { background: var(--paper-sunk); color: var(--ink); }
.chat-iconbtn.chat-send { background: var(--ink); border-color: var(--ink); color: var(--paper-on-ink); }
.chat-iconbtn.chat-send:hover { background: var(--ink-secondary); border-color: var(--ink-secondary); }
.chat-iconbtn.chat-send:disabled {
  background: var(--paper-sunk); border-color: var(--line); color: var(--line-strong); cursor: default;
}
.chat-iconbtn:disabled { cursor: default; opacity: 0.6; }

/* ============================================================ quick invoice
   Landing screen for a new invoice — see invoice-quick.js. Reuses `.field`,
   `.fgrid` and `.btn` throughout; only the shapes with no equivalent
   elsewhere (the two big tiles, the segmented toggles, the total) are new. */

.quickpane { max-width: 640px; margin-inline: auto; padding: 4px 0 40px; }

/* Order subpages only (order-quick.js) — a stone card grid, the accent
   table's nine columns and the costing block's THB/USD pairs all read fine
   at the full form's own width (`.form`'s 1000px cap, reused here rather
   than inventing a second number) and genuinely do not at 640px: species
   names in the accent table were clipped to "Diamo…"/"Tsavo…" before this
   existed, found in the 23 Sep 2026 layout audit. Home tiles and the plain
   field-grid subpages (Order, Client, The piece, Metal & setting, Custody)
   stay at the narrower default — a few short fields stretched to 1000px
   reads as empty, the same reasoning invoice-quick.js's own subviews
   already rely on. See `WIDE_SECTION_TYPES` in order-quick.js for exactly
   which section types this applies to. */
.quickpane.wide { max-width: 1000px; }

/* 18px between every top-level block in the pane (tiles, currency, total,
   payment) — one number, not four close-but-different ones. Found during
   the 15 Sep 2026 layout audit: this was 22px while its neighbours below
   were all 18, which is exactly small enough to not name a reason and
   large enough to read as unplanned rather than deliberate. */
.qc-tiles { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
.qc-tile {
  display: flex; align-items: center; gap: 14px; text-align: left;
  background: var(--paper); border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg); corner-shape: squircle;
  padding: 16px; cursor: pointer; color: var(--ink); font: inherit;
}
.qc-tile:hover { background: var(--paper-sunk); }
.qc-tile-body { flex: 1; min-width: 0; }
.qc-tile-label { font-size: 15px; font-weight: 500; }
.qc-tile-sub { font-size: 12.5px; color: var(--ink-tertiary); margin-top: 2px; }
.qc-tile-go { color: var(--ink-muted); flex: none; }

.qc-segrow { margin-bottom: 18px; }
.qc-seglabel {
  font-size: 11px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ink-tertiary); margin-bottom: 6px;
}
/* The pill slides rather than snaps — Joe, 15 Sep 2026, picked the "tactile"
   direction over a rounded/bevelled one for THIS control (Gemstone/Other):
   zero radius and no shadow, matching every other control in the app, and
   the sense of a physical switch comes from PRESSING (`.pressing` below,
   scaling the fill down on mousedown/touchstart) rather than from fake
   depth. `.fill` is positioned in JS with `left`/`width` in pixels rather
   than percentages, because the two buttons in a pair are rarely the same
   width. See segToggle() in invoice-quick.js.

   ⚠ The currency toggle (USD/THB) no longer uses this — Joe picked a round
   knob switch for that one instead, `.qc-curswitch` below. Two different
   shapes for two different controls, deliberately: this file used to say
   "every control in the app" stayed zero-radius, which stopped being true
   the moment that decision was made, so it is worth knowing this class no
   longer speaks for the whole pane, only for Gemstone/Other. */
.qc-seg {
  position: relative; display: inline-flex; border: 1px solid var(--line-strong);
  background: var(--paper); width: fit-content;
  /* Clips the fill to the frame during the slide — see the transition below,
     which overshoots past 100% on purpose, so without this the fill poked
     out past the border mid-animation. `button:focus-visible` below uses
     `outline-offset: -2px` (draws inward, not out), so the focus ring stays
     inside this box and is never clipped by it. Found 16 Sep 2026. */
  overflow: hidden;
}
.qc-seg button {
  position: relative; z-index: 1; font: inherit; font-size: 13px; padding: 9px 18px;
  border: none; background: transparent; color: var(--ink-secondary); cursor: pointer;
  font-weight: 500; white-space: nowrap; transition: color 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.qc-seg button.on { color: var(--paper-on-ink); }
.qc-seg button:focus-visible { outline: 2px solid var(--accent-bronze); outline-offset: -2px; }
.qc-seg.small button { padding: 6px 12px; font-size: 12px; }

/* `.qc-seg-fill`, not the bare `.fill` this used to be — `sheet.css` has its
   own global `.fill` for the printed sheet's blank write-in lines, and the
   generic name collided with it: the toggle's fill inherited a 70px
   min-width and a border-bottom meant for those lines, rendering wider than
   its own inline `width` and trailing a stray line under it. See segToggle()
   in invoice-quick.js. Found 16 Sep 2026. */
.qc-seg .qc-seg-fill {
  position: absolute; top: 0; left: 0; height: 100%; background: var(--accent-bronze); z-index: 0;
  transition: left 0.4s cubic-bezier(0.34, 1.2, 0.64, 1), width 0.4s cubic-bezier(0.34, 1.2, 0.64, 1),
              transform 0.15s ease;
}
.qc-seg.pressing .qc-seg-fill { transform: scaleY(0.82); }

@media (prefers-reduced-motion: reduce) {
  .qc-seg .qc-seg-fill, .qc-seg button { transition: none; }
}

/* CURRENCY — a round knob switch, not a segmented pill. Joe, 15 Sep 2026,
   from a reference screenshot: bronze track and paper knob (the app's own
   tokens, not the reference's green), $ / ฿ in place of "USD"/"THB" text.
   The track stays bronze in BOTH states — USD and THB are two equally valid
   choices, not an on/off pair, so nothing here should read as "off". Only
   the knob's side and which symbol it carries change. The unselected
   symbol stays visible but dim on the exposed track rather than
   disappearing, so both choices are legible at a glance without clicking. */
.qc-curswitch {
  /* ⚠ Same fix as `.langtoggle` in app.css (found via that control on an
     iPhone, 15 Sep 2026): without this, iOS Safari renders a `<button>` with
     its own native bordered rectangle instead of honouring `border-radius`
     at all. Applied here pre-emptively — same shape of control, same bug. */
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
  position: relative; width: 72px; height: 40px; background: var(--accent-bronze);
  border-radius: 999px; border: none; padding: 0; cursor: pointer; flex: none;
}
.qc-curswitch:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

.qc-curswitch .sym {
  position: absolute; top: 0; height: 100%; width: 36px;
  display: flex; align-items: center; justify-content: center;
  font-size: 15px; font-weight: 500; color: rgba(250, 248, 244, 0.55);
  transition: color 0.32s cubic-bezier(0.4, 0, 0.2, 1); pointer-events: none;
}
.qc-curswitch .sym.usd { left: 0; }
.qc-curswitch .sym.thb { right: 0; }
.qc-curswitch[data-value="USD"] .sym.usd,
.qc-curswitch[data-value="THB"] .sym.thb { color: var(--paper-on-ink); }

.qc-curswitch .knob {
  position: absolute; top: 2px; left: 2px; width: 36px; height: 36px;
  background: var(--paper); border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 15px; font-weight: 600; color: var(--accent-bronze);
  transition: transform 0.4s cubic-bezier(0.34, 1.2, 0.64, 1), width 0.15s ease, left 0.15s ease;
}
.qc-curswitch[data-value="THB"] .knob { transform: translateX(32px); }
.qc-curswitch.pressing .knob { width: 40px; }
.qc-curswitch.pressing[data-value="THB"] .knob { left: -2px; }

@media (prefers-reduced-motion: reduce) {
  .qc-curswitch .knob, .qc-curswitch .sym { transition: none; }
}

.qc-total {
  background: var(--paper-sunk); border: 1px solid var(--line-strong);
  padding: 18px 20px; margin-bottom: 18px;
}
.qc-total-label {
  font-size: 11px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ink-tertiary);
}
.qc-total-fig {
  font-size: 32px; font-weight: 300; color: var(--ink); line-height: 1.2;
  font-variant-numeric: tabular-nums; margin-top: 4px;
}
.qc-total-note { font-size: 12.5px; color: var(--status-error); margin-top: 6px; }

.qc-pay { margin-bottom: 18px; }
/* A fixed 2-column GRID, not flex-wrap. Four payment options at four
   different label lengths ("Credit Card" vs "Cash — USD or THB") wrapped
   3-then-1 under flex-wrap on a phone — a lopsided last row that looked
   accidental. Two even columns wrap the same way every time, on every
   phone width, regardless of which label is longest. */
.qc-paybtns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.qc-paybtns button {
  font-size: 12.5px; padding: 10px 14px; border: 1px solid var(--line-strong);
  background: var(--paper); color: var(--ink-secondary); cursor: pointer;
  line-height: 1.3; display: flex; align-items: center; gap: 8px; text-align: left;
}
/* `currentColor`, same as every other icon in the app — so the mark goes
   bronze-on-paper→paper-on-bronze with the button's own text colour and
   needs no rule of its own for the selected state below. */
.qc-paybtns button .icon { flex: none; }
.qc-paybtns button.on { background: var(--accent-bronze); color: var(--paper-on-ink); border-color: var(--accent-bronze); }

.qc-sub { padding-top: 2px; }
.qc-subhead { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.qc-subhead h3 { font-size: 18px; font-weight: 500; margin: 0; }
.qc-subhead .backbtn { display: inline-block; padding-left: 0; }

/* Every block in a subview — a two/three-column `.fgrid` row or a standalone
   field wrapped in its own single-column `.fgrid` (see customerView() in
   invoice-quick.js) — is 14px from the next, the same row-gap `.fgrid` itself
   already uses between fields. Before 16 Sep 2026 a standalone `.field`
   appended straight to `.qc-sub` carried no spacing of its own, so e.g.
   Company name touched the grid above and below it with 0px between them. */
.qc-sub > .fgrid + .fgrid { margin-top: 14px; }
/* The client-record strip at the top of the Client screen (invoice-quick.js,
   reusing form.js's `clientRow`) — it sits under the heading rule already, so
   no border of its own, and the same 14px down to the name boxes. */
.qc-sub > .linkrow { margin-top: 0; padding-top: 0; border-top: 0; margin-bottom: 14px; }
/* The client typeahead's list sits between the name row and Company; keep
   the 14px rhythm across it. */
.qc-sub > .qc-suggest + .fgrid { margin-top: 14px; }

.qc-checkline {
  display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-secondary);
  /* 14px both sides, same as the `.fgrid + .fgrid` rule above — this line
     sits between two fields (billing address above, shipping address below)
     and is a block in the same stack, not a caption glued to one side of it.
     Was `4px 0 14px` (an unexplained 4px above), 16 Sep 2026. */
  margin: 14px 0;
}

.qc-items { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
/* `justify-content: space-between` (the pre-21-Sep-2026 rule) was right for
   two children — the body and a single trash button — and spread FOUR of
   them (thumb, body, edit, trash) evenly across the row instead, once the
   entry grew a thumbnail and an Edit button. Plain flex + `.qc-item-body`
   taking the slack is the same layout every other row-with-fixed-ends
   pattern in this app already uses. */
.qc-item {
  display: flex; align-items: center; gap: 10px;
  background: var(--paper); border: 1px solid var(--line); padding: 10px 12px;
}
/* The row currently open in the add-item form below, so it reads as "this
   one" rather than looking identical to the other, inert rows above it. */
.qc-item.editing { border-color: var(--accent-bronze); }
.qc-item-body { flex: 1; min-width: 0; }
.qc-item-name { font-size: 14px; }
.qc-item-sub { font-size: 12.5px; color: var(--ink-tertiary); margin-top: 2px; }
.qc-item-empty { font-size: 13px; color: var(--ink-tertiary); padding: 8px 0; }

/* The entry's own thumbnail — a photo, or the same "no photo yet" frame
   `.r-thumb.empty` (records.js's list rows) and the chat-card idiom
   (assistant.js) already use, never a bare blank square. 40px: smaller than
   a list row's cover photo (this sits beside two icon buttons in a compact
   line, not as a card's own header), but the same border and fill as every
   other photo frame in the app (`--radius-lg`/squircle — brand tokens call
   this "image frames"). */
.qc-item-thumb {
  width: 40px; height: 40px; flex: none; overflow: hidden;
  border: 1px solid var(--line-strong); border-radius: var(--radius-lg); corner-shape: squircle;
  background: var(--paper-sunk); display: flex; align-items: center; justify-content: center;
}
.qc-item-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.qc-item-thumb.empty .icon { color: var(--line-strong); }

/* The row's own linked photos, shown only inside edit mode (invoice-quick.js
   editPhotos()) — a small grid of chips, each with its own remove control
   riding in the corner rather than beside it, since there is no room for a
   second row of text at this size. */
.qc-itemphotos { display: flex; flex-wrap: wrap; gap: 8px; }
.qc-itemphoto {
  position: relative; width: 56px; height: 56px; flex: none; overflow: hidden;
  border: 1px solid var(--line-strong); border-radius: var(--radius-lg); corner-shape: squircle;
  background: var(--paper-sunk);
}
.qc-itemphoto img { width: 100%; height: 100%; object-fit: cover; display: block; }
.qc-itemphoto .iconbtn {
  position: absolute; top: 2px; right: 2px; width: 20px; min-height: 20px;
  background: var(--paper); border: 1px solid var(--line-strong);
}

/* One gap for "trailing action row that closes out a card or subview" — the
   two Done buttons and the add-item form's own Add item/Image row were each
   carrying their own inline margin-top (14px, 14px, 10px) before the 15 Sep
   2026 layout audit; same job, no reason for three separate numbers. */
.qc-trailrow { margin-top: 14px; }

.qc-additem {
  border: 1px dashed var(--line-strong); background: var(--paper-sunk);
  padding: 14px; display: flex; flex-direction: column; gap: 12px;
}
.qc-additem-row { display: flex; gap: 8px; flex-wrap: wrap; }
/* The Discount screen (invoice-quick.js, 26 Sep 2026): the subtotal it comes
   off, stated above the three boxes, and one quiet line under them. */
.qc-disc-base {
  display: flex; justify-content: space-between; align-items: baseline;
  padding: 10px 0; margin-bottom: 14px; border-bottom: 1px solid var(--line);
  font-size: 13px; color: var(--ink-tertiary);
}
.qc-disc-fig { font-size: 15px; color: var(--ink); font-variant-numeric: tabular-nums; }
.qc-disc-after { font-size: 12.5px; color: var(--ink-tertiary); margin-top: 8px; }
.qc-disc-after:empty { display: none; }

/* `.btn` sets `white-space: nowrap` for the one-line pill shape every other
   button on the app wants — this label is a sentence, not a pill, and on a
   360–400px phone it does not fit on one line. Left unset it forced the
   button (and everything else in `.quickpane`) wider than the screen,
   dragging the whole pane into horizontal scroll — found 14 Sep 2026
   auditing the mobile breakpoint for exactly that. Wrapping it, full width
   and left-aligned, is the same call already made for `h2.sect .tagmaster`
   above: allowed to run to two lines, never clipped or dragging the page
   sideways to stay on one. */
.qc-fulllink {
  white-space: normal; width: 100%; justify-content: flex-start;
  text-align: left; height: auto; line-height: 1.4;
}
.qc-fulllink .icon { margin-top: 2px; }
