/* =====================================================================
   MLE Field Tools - stylesheet
   Design constraints driven by the actual use case:
     - read in direct sunlight on a dock  -> very high contrast, no grey-on-grey
     - operated with one hand, possibly gloved -> 48px minimum touch targets
     - iOS Safari must not zoom on focus  -> all inputs >= 16px
     - nav within thumb reach -> bottom tab bar, safe-area aware
   ===================================================================== */

:root {
  /* THE FIRM PALETTE (Maffett Loftis brand guideline): slate #4E5E78,
     sky #C5D5EA, warm light #EAE6E3, warm grey #827970, dark grey
     #545454, black, white. Tints below that are not in the guideline are
     derived from those and named as such. `--navy` keeps its name as the
     app bar token; its value is the brand slate. */
  --navy:      #4e5e78;
  --accent:    #4e5e78;
  --brand-sky:  #c5d5ea;
  --brand-warm: #827970;

  /* THE INK PALETTE. These five must equal INK_COLOURS in portal-core -
     resolveInk resolves a stored mark through that object and the PDF
     export renders from it, so a swatch that disagreed here would be a
     swatch that draws a different colour than it shows. A test asserts
     the two match; see "the ink swatches match INK_COLOURS" in the smoke
     suite. */
  --ink-red:    #d21f1f;
  --ink-blue:   #0069c0;
  --ink-green:  #1b7a3c;
  --ink-black:  #141a20;
  --ink-yellow: #e8b400;
  --accent-lo: #dfe7f2;        /* derived: sky #C5D5EA lifted toward white */
  /* Over the drawing, which is paper in either scheme. */
  --sheet-link: #4e5e78;
  --sheet-link-fill: rgba(78,94,120,.10);
  --find-hit: #c79a00;
  --find-hit-fill: rgba(255,214,0,.38);
  --find-current: #d0580f;
  --find-current-fill: rgba(255,153,0,.45);
  --ok:        #1b6e3c;
  --ok-bg:     #e6f4ea;
  --warn:      #8a4b00;
  --warn-bg:   #fff4e0;
  /* The frame round a sheet shown at an older revision than current. */
  --rev-old:   #e07a00;
  --rev-old-ink: #ffffff;
  --bad:       #a01b1b;
  --bad-bg:    #fdeaea;
  --ink:       #000000;
  --ink-2:     #545454;
  --line:      #d3ccc6;        /* derived: warm light #EAE6E3, one step darker */
  --line-2:    #eae6e3;
  --bg:        #ffffff;
  --bg-2:      #f5f3f1;        /* derived: halfway from white to #EAE6E3 */
  /* The page sits one step below the card surface (--bg), so a card reads
     as a raised object rather than as a ruled box. */
  --bg-page:   #eae6e3;
  --shadow-1:  0 1px 2px rgba(84, 84, 84, .08), 0 2px 8px rgba(84, 84, 84, .07);
  --shadow-2:  0 2px 4px rgba(84, 84, 84, .10), 0 8px 24px rgba(84, 84, 84, .12);
  --ease-out:  cubic-bezier(.2, .8, .2, 1);
  /* Solid fills that carry white text. Equal to the accent and bad
     colours in light mode; in dark mode the text accent is the light sky
     blue, which cannot carry white text, so the fill stays slate. */
  --accent-fill: var(--accent);
  --on-accent:   #ffffff;      /* text and icons on --accent-fill */
  --bad-fill:    var(--bad);
  --field:       #ffffff;      /* input and select wells */
  --bar-edge:    rgba(0, 0, 0, .25);
  /* A tile inside a card: raised and outlined on light, an inset well on
     dark, where an outline inside an outlined card reads as clutter. */
  --tile-bg:     var(--bg);
  --tile-edge:   var(--line-2);
  --radius:    14px;
  /* THE TYPE SCALE. Every font-size in this file is one of these; a
     half-pixel or one-off size is how a screen ends up with nine
     slightly different small labels. */
  --fs-tiny:    9.5px;       /* pin counters, chart ticks */
  --fs-micro:   10px;
  --fs-cap:     11px;        /* badges, captions */
  --fs-small:   12px;
  --fs-note:    13px;
  --fs-body:    14px;
  --fs-field:   15px;        /* list rows, form fields */
  --fs-lead:    16px;
  --fs-title:   17px;        /* card and dialog titles, row values */
  --fs-head:    20px;
  --fs-display: 24px;
  --fs-hero:    30px;
  /* THE RADIUS SCALE. Half-height pills and circles keep their own
     geometric radius (50%, 999px, or half the control's height). */
  --r-xs:  4px;
  --r-sm:  8px;
  --r-md:  12px;
  --tap:       48px;
  /* The camera viewfinder is black with white controls in both schemes,
     as every camera is. */
  --cam-bg:    #000;
  --cam-ink:   #fff;
  /* THE TAB BAR IS A FIXED ROW, NOT A CONTENT-SIZED ONE - see nav.tabs
     button. 56px clears the icon, the gap, one line of label at 11px/1.2
     and the padding and border, with a few pixels spare for a font
     fallback that measures slightly taller. */
  --tabh:      56px;
  /* The bottom safe-area inset, named once so the tab bar and anything
     else that clears the home indicator cannot drift apart.

     A plain alias, nothing more. It was briefly latched from JavaScript on
     the theory that the platform was re-reporting it and dragging the tab
     bar's height around; measuring on the device showed it steady at 25px
     in both the states where the bar looked different, so the latch was
     removed rather than left in to defend a problem that was not there. */
  --safe-b:    env(safe-area-inset-bottom, 0px);
  /* Brand type: Arial for body, Poppins SemiBold for headers, Poppins
     Black for emphasis. Arial is a system font on every platform but
     Android, where Helvetica/Roboto stand in. */
  --font: Arial, "Helvetica Neue", Helvetica, Roboto, sans-serif;
  --font-head: "Poppins", Arial, "Helvetica Neue", Helvetica, sans-serif;
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

/* Poppins, vendored and in the service worker SHELL so it is present
   offline. Latin subset, the two weights the brand uses. `swap` so text
   is never invisible while a file loads. */
@font-face {
  font-family: "Poppins";
  src: url("vendor/poppins/poppins-latin-600-normal.woff2") format("woff2");
  font-weight: 600; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Poppins";
  src: url("vendor/poppins/poppins-latin-900-normal.woff2") format("woff2");
  font-weight: 900; font-style: normal; font-display: swap;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* DARK IS SLATE NIGHT: every surface is the brand slate #4E5E78 taken
       down in value, so the bar, the cards and the wells read as one
       family. Sky #C5D5EA is the accent and the button fill, carrying
       dark text; elevation is a lighter surface plus a hairline. */
    --navy: #1f2633;
    --accent: #c5d5ea; --accent-lo: #33405a;
    --ok: #6ee7a0; --ok-bg: #12301f;
    --warn: #ffc266; --warn-bg: #35270f;
    --rev-old: #ffad33; --rev-old-ink: #1a202b;
    --bad: #ff8f8f; --bad-bg: #3a1414;
    --ink: #eef1f6; --ink-2: #aab4c4;
    --line: #465164; --line-2: #2c3443;
    --bg: #1a202b; --bg-2: #232a37; --bg-page: #0f1318;
    --field: #11161e;
    --accent-fill: #c5d5ea; --on-accent: #11151c; --bad-fill: #c93434;
    /* Shadows do not read on a dark page; elevation is carried by the
       lighter surface instead, with a hairline to hold the edge. */
    --shadow-1: 0 0 0 1px rgba(255, 255, 255, .06);
    --bar-edge: rgba(255, 255, 255, .08);
    --tile-bg: var(--bg-2); --tile-edge: transparent;
    --shadow-2: 0 0 0 1px rgba(255, 255, 255, .06), 0 8px 24px rgba(0, 0, 0, .45);
  }
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

/* ---------- icons ---------- */

/* The sprite itself is never displayed; only its <symbol>s are referenced. */
svg.sprite { display: none; }

/* One rule drives every icon in the app. `stroke: currentColor` is what makes
   an icon follow its surrounding text colour - the selected tab's accent, the
   header's white, the dark-scheme ink - and it is the reason these are drawn
   as SVG rather than set as emoji characters, which carry their own colour and
   ignore CSS entirely. `stroke` and `fill` are inherited SVG properties, so
   they reach the referenced symbol's paths through <use>; the symbols
   deliberately set neither. */
svg.ic, svg.home-ic {
  display: block; flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* The [hidden] attribute must always win over any component's own display
   rule (e.g. `nav.tabs button { display: flex }` below) - author rules that
   set `display` otherwise silently override the UA's [hidden]{display:none},
   regardless of selector specificity, because that comparison is by origin
   first. Toggling `.hidden = true` from ui.js relies on this. */
[hidden] { display: none !important; }

html, body {
  margin: 0; padding: 0;
  background: var(--bg-page); color: var(--ink);
  font-family: var(--font);
  font-size: var(--fs-title); line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  -webkit-text-size-adjust: 100%;
  overscroll-behavior-y: contain;
}

/* `manipulation` allows scrolling and pinch-zoom but disables double-tap
   to zoom. Double-tapping the chrome used to zoom the whole page, header
   and all, which in an installed app reads as a glitch rather than a
   feature - and the viewer uses double-tap for its own zoom, so the two
   were fighting.

   Pinch-zoom is deliberately left alone: it is the only way to enlarge
   small text for someone who needs it, and taking it away to fix a
   double-tap is a bad trade. Note `manipulation`, not `none`: none on the
   body would kill page scrolling entirely.

   overflow-x is `clip`, never `hidden` - hidden makes the viewport a
   scroll container, which breaks desktop wheel scrolling and stops the
   sticky header sticking. */
/* =====================================================================
   NOTHING ON <body> OR <html> MAY ESTABLISH A CONTAINING BLOCK.

   THE TAB BAR NO LONGER DEPENDS ON THIS - it is sticky now, laid out in
   flow - but the rule has not gone away, it has only changed owner. The
   viewer, the annotator and the About sheet are all `position: fixed` and
   all still need the viewport as their containing block, so a property on
   <html> or <body> that establishes one breaks the full-screen overlays
   instead of the bar. `overflow` (any value other than visible),
   `transform`, `filter`, `perspective`, `backdrop-filter`, `contain` and
   `will-change` all do exactly that.

   `display: flex` on <body>, added for the shell, is NOT on that list and
   does not establish one.

   `body { overflow-x: clip }` used to be here, and it is what made the
   bottom bar sit above the bottom of the screen on iPad, move while the
   page was scrolled, and settle at a different height on each panel
   depending on how much content the panel had - the bar was being
   positioned and clipped against the BODY box, whose bottom edge moves
   with the document, instead of against the viewport. It was correct on
   desktop Chrome and wrong in WebKit because clipping of fixed-position
   descendants is genuinely unspecified: see csswg-drafts issue 8336.

   It arrived as the fix for an earlier bug - `overflow-x: hidden` on
   `html, body` makes the viewport a scroll container, which stops the
   mouse wheel scrolling the page and stops `position: sticky` sticking on
   the app bar. Neither belongs here. Horizontal overflow is contained
   where it is actually generated instead: `section.panel` clips its own,
   and anything genuinely wider than the viewport - the reference legend
   tables, the marina grid - gets its own scroll box via `.tblwrap`.

   Before adding any property to these two selectors, check it against the
   list above. If it is on that list, the tab bar will come unstuck again,
   on WebKit only, and it will present as a layout problem rather than as a
   positioning one.
   ===================================================================== */
/* =====================================================================
   THE APP SHELL IS A COLUMN, AND THE TAB BAR IS THE LAST ROW OF IT.

   The bar used to be `position: fixed; bottom: 0`. It changed height
   between panels on an installed iPad, and three attempts to fix that by
   hardening its height failed - correctly, because its height was never
   the problem. The device measurement settled it: `--tabh` is a literal
   56px, and `env(safe-area-inset-bottom)` reads a steady 25px whether the
   panel scrolls or not. Every term in the bar's height is a constant, so
   the bar's BOX was never growing. What moved was where the bar sat: on a
   panel with too little content to scroll it rendered above the bottom of
   the screen, and since the bar's background and the body's background are
   both `--bg`, the white band underneath it is indistinguishable from a
   taller bar. It was a positioning bug wearing a sizing bug's clothes,
   which is exactly why measuring beat guessing.

   So the bar no longer asks the platform to position it. `body` is a flex
   column at least one viewport tall, `main` is the flex item that grows,
   and the bar is simply the row after it - it sits at the bottom of a
   short page because there is nothing left below it to sit above.

   `position: sticky; bottom: 0` (on the bar's own rule) is what keeps it
   there once the page is long enough to scroll. Sticky, NOT fixed: sticky
   is resolved in normal flow against this element's box, so it never
   consults the viewport geometry that was getting the old bar wrong.

   Note what this deliberately does NOT do: it does not make `main` a
   scroll container. The viewer and the annotator are `position: fixed;
   inset: 0` and they live inside `<main>`; clipping of fixed descendants
   by a scrolling ancestor is genuinely unspecified in WebKit (csswg-drafts
   8336), and the app has already been bitten by that once. The viewport
   stays the scroller, so `window.scrollTo` still means what it says and
   the sticky header still sticks.
   ===================================================================== */
body {
  touch-action: manipulation;
  display: flex; flex-direction: column;
  min-height: 100vh;    /* fallback first, for anything without dvh */
  min-height: 100dvh;
}
/* Every row of the shell keeps its natural height; only `main` absorbs the
   slack. Without this a short panel would let the flex algorithm shrink
   the header or the bar rather than leaving the gap in the middle. */
header.appbar, .updatebar, .errbar, nav.tabs { flex: none; }

/* ---------- app frame ---------- */

header.appbar {
  position: sticky; top: 0; z-index: 40;
  background: linear-gradient(rgba(255, 255, 255, .07), rgba(255, 255, 255, 0)) var(--navy);
  color: #fff;
  padding: calc(env(safe-area-inset-top, 0px) + 10px) 14px 10px;
  display: flex; align-items: center; gap: 10px;
  box-shadow: 0 1px 0 var(--bar-edge);
}
header.appbar .home-icon-btn {
  flex: none; width: 34px; height: 34px; min-height: 0;
  border: 0; border-radius: var(--r-sm); background: rgba(255,255,255,.14);
  color: #fff; cursor: pointer;
  display: flex; align-items: center; justify-content: center; padding: 0;
}
header.appbar .home-icon-btn .ic { width: 20px; height: 20px; }
header.appbar .home-icon-btn:active { background: rgba(255,255,255,.28); }
/* SITS BESIDE THE HOME BUTTON, not in its slot. Same height and pressed
   state so the pair reads as one group. Text as well as an arrow: "back"
   alone in an app this deep does not say back to where, and the label
   is written per press with the name of the destination.

   `max-width` with an ellipsis is the part that matters now the label
   varies. It used to be the fixed string "All Projects"; a destination
   name is whatever somebody called a tool or a panel, and an unbounded
   label in a flex row pushes the page title, the account chip and the
   version straight off the right-hand edge. */
header.appbar .backlink {
  display: inline-flex; align-items: center; gap: 4px; flex: none;
  border: 0; background: rgba(255,255,255,.16); color: #fff;
  border-radius: var(--r-sm); padding: 0 10px 0 6px; min-height: 34px;
  font: inherit; font-size: var(--fs-body); font-weight: 600; cursor: pointer;
  white-space: nowrap; max-width: 38vw; overflow: hidden;
}
header.appbar .backlink .ic { width: 18px; height: 18px; }
header.appbar .backlink span {
  min-width: 0; overflow: hidden; text-overflow: ellipsis;
}
header.appbar .backlink:active { background: rgba(255,255,255,.28); }
/* Two controls plus a title is a lot of bar on a small phone, so the
   back label goes and the arrow stays - the arrow is the control, the
   word was only ever telling you where it led. The title then has the
   width, and it is the thing that says where you are. */
@media (max-width: 430px) {
  header.appbar .backlink span { display: none; }
  header.appbar .backlink { padding: 0 6px; }
}

/* The "MLE" chip was here. Removed with the "Field Tools" prefix to give
   the bar's width to the page name, the account chip and the version.
   The mark is still on the home screen and in the installed icon, which
   is where branding is actually seen. */
header.appbar h1 { font-family: var(--font-head); font-size: var(--fs-lead); margin: 0; font-weight: 600; flex: 1; }

/* THE VERSION IS THE BUTTON, and the (i) that used to sit beside it is
   gone. Two controls in the corner answered one question, and the icon
   was the smaller target of the two. Given a real tap target here, the
   number is the better one: it is what someone is looking at when they
   wonder whether they are up to date.

   Styled as a button that does not look like one at rest - the bar is
   already busy - but it carries the full 28px height so it is hittable
   with a thumb, which a bare 12px span was not. */
/* The account chip and the version travel together: in the app bar, or
   at the right of the sheet viewer's bar while it covers the app bar. */
.appid { display: inline-flex; align-items: center; gap: 10px; flex: none; }
.vwbar .appid { gap: 8px; margin-left: auto; }
.appid .ver-ic { display: none; width: 16px; height: 16px; }
/* A phone's viewer bar has no room for the number beside the sheet
   title, so the version shows as an info icon there; it opens the same
   About sheet, which states the version. */
@media (max-width: 430px) {
  .vwbar .appid .ver { display: none; }
  .vwbar .appid .ver-ic { display: block; }
  .vwbar .appid .verbtn { padding: 0 6px; }
}
.vwtitle strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.appid .verbtn {
  flex: none; min-height: 0; height: 28px; padding: 0 8px;
  border: 0; border-radius: var(--r-sm); background: rgba(255, 255, 255, .14);
  color: #fff; cursor: pointer;
  display: inline-flex; align-items: center;
}
.appid .verbtn:active { background: rgba(255, 255, 255, .3); }
.appid .ver { font-size: var(--fs-small); opacity: .85; font-variant-numeric: tabular-nums; }

/* WHO IS SIGNED IN. Two shapes from one element: a "Sign in" pill when
   nobody is, and a round initials chip once somebody is. One control in
   one place either way, so the bar does not reflow as the state changes
   and nobody has to learn a second location. */
.appid .acctbtn {
  flex: none; min-height: 0; height: 28px; padding: 0 10px;
  border: 0; border-radius: var(--radius); background: rgba(255, 255, 255, .14);
  color: #fff; cursor: pointer; font: inherit;
  font-size: var(--fs-small); font-weight: 600; white-space: nowrap;
  display: inline-flex; align-items: center; gap: 5px;
}
.appid .acctbtn:active { background: rgba(255, 255, 255, .3); }
.appid .acctbtn .ic { width: 15px; height: 15px; }
/* Signed in: a circle of initials. Fixed width so it does not jump about
   as the name behind it changes. */
.appid .acctbtn.signed {
  width: 28px; padding: 0; border-radius: 50%;
  justify-content: center; letter-spacing: .02em;
  background: rgba(255, 255, 255, .26);
}
/* Waiting for approval, or access withdrawn. Amber rather than red: on
   the sign-in screen this is a normal state of a new starter, and a bar
   that looks like an error every time somebody is queued is a bar people
   stop reading. */
.appid .acctbtn.attention { background: rgba(245, 158, 11, .38); }

/* The name is the first thing to give up room, before the title does. */
@media (max-width: 420px) {
  .appid .acctbtn .acct-label { display: none; }
  .appid .acctbtn { padding: 0 8px; }
}

/* The About sheet. Fixed under the header rather than a panel, so it opens
   over whatever you were doing - including a marked-up photo - and closes
   back to it. */
.infosheet {
  position: fixed; z-index: 60;
  top: calc(env(safe-area-inset-top, 0px) + 52px);
  right: 10px; left: auto; width: min(380px, calc(100vw - 20px));
  max-height: min(70vh, 620px); overflow-y: auto;
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: 0 8px 30px rgba(0, 0, 0, .36);
  padding: 12px 14px 14px;
}
.infosheet-head {
  display: flex; align-items: center; gap: 10px; margin-bottom: 6px;
}
.infosheet-head strong { flex: 1; font-size: var(--fs-lead); }
.infosheet .vwbtn { width: 32px; height: 32px; }

/* The account menu. Anchored under the header like the About sheet. */
.acctmenu {
  position: fixed; z-index: 60;
  top: calc(env(safe-area-inset-top, 0px) + 52px);
  right: 10px; left: auto; width: min(260px, calc(100vw - 20px));
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: 0 8px 30px rgba(0, 0, 0, .36);
  padding: 6px;
}
.acctmenu-who {
  padding: 8px 10px 10px; border-bottom: 1px solid var(--line-2); margin-bottom: 4px;
  font-size: var(--fs-small); color: var(--ink-2);
}
.acctmenu-who strong { display: block; color: var(--ink); font-size: var(--fs-body); }
.acctmenu-item {
  display: flex; align-items: center; gap: 10px; width: 100%;
  min-height: 44px; padding: 0 10px;
  border: 0; border-radius: var(--r-sm); background: transparent;
  color: var(--ink); font: inherit; font-size: var(--fs-body); text-align: left; cursor: pointer;
}
.acctmenu-item:hover, .acctmenu-item:focus-visible { background: var(--bg-2); }
.acctmenu-item:active { background: var(--accent-lo); }
.acctmenu-item .ic { width: 20px; height: 20px; flex: none; }

header.appbar .netdot {
  width: 10px; height: 10px; border-radius: 50%;
  background: #4ade80; flex: none;
}
header.appbar .netdot.off { background: #fbbf24; }
/* A link that reports itself up but carries nothing: amber, ringed. */
header.appbar .netdot.unreachable { background: #fbbf24; box-shadow: 0 0 0 2px rgba(255, 255, 255, .55); }
/* Work offline: hollow, since the device chose not to sync. */
header.appbar .netdot.paused { background: transparent; box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .8); }
/* While a sync runs (body.syncing, set by portal-ui) the dot turns blue and
   pulses, so the transfer is visible from any screen. */
@keyframes syncpulse {
  0%   { box-shadow: 0 0 0 0 rgba(197, 213, 234, .8); }
  100% { box-shadow: 0 0 0 7px rgba(197, 213, 234, 0); }
}
body.syncing header.appbar .netdot {
  background: var(--brand-sky); animation: syncpulse 1.1s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  body.syncing header.appbar .netdot { animation: none; }
}

/* Update banner. Sits directly under the app bar and sticks with it, so a
   user scrolled deep into a calculator still sees it. Deliberately not a
   modal and not a toast: it must not cover a field or time itself out
   while someone is mid-entry on a dock, and it must survive being ignored
   until they are ready to reload. */
.updatebar {
  position: sticky; top: 0; z-index: 39;
  background: var(--accent-lo); color: var(--ink);
  border-bottom: 1px solid var(--line);
  padding: 9px 14px;
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  font-size: var(--fs-body);
}
/* The gate's line. Same shell slot as the update and error bars - it is
   `flex: none` in the rule above with them, so it takes its own height
   and never steals it from the scrolling panel underneath.

   Muted rather than alarming on purpose. Every message it carries is a
   normal state of a field app - offline, waiting for approval, session
   run out - and the one genuinely bad state (access withdrawn) says so in
   its own words rather than by turning the whole bar red. A banner that
   looks like an error every time somebody walks out of signal is a banner
   people stop reading. */
.gatebar {
  position: sticky; top: 0; z-index: 38;
  background: var(--accent-lo); color: var(--ink);
  border-bottom: 1px solid var(--line);
  padding: 8px 14px;
  font-size: var(--fs-body); font-weight: 600;
}

/* Read-only, after access is withdrawn. The records stay - and stay
   exportable, deliberately - but nothing new is authored, so the controls
   that would create something are put beyond reach rather than left to
   fail on a write. */
body.portal-readonly .btn.creates { display: none; }

.updatebar .ic { width: 18px; height: 18px; flex: none; color: var(--accent); }
.updatebar-text { flex: 1; min-width: 0; font-weight: 600; }
.updatebar button {
  flex: none; min-height: 32px; padding: 0 12px;
  border-radius: var(--r-sm); font-size: var(--fs-body); font-weight: 600; cursor: pointer;
}
.updatebar-go { border: 0; background: var(--accent-fill); color: var(--on-accent); }
.updatebar-go:active { filter: brightness(.9); }
.updatebar-later {
  border: 1px solid var(--line); background: transparent; color: var(--ink-2);
  font-weight: 500;
}

/* Background-failure banner. Same shape as the update bar, alarming
   colours, and deliberately NOT sticky - it reports something that has
   already happened, so it does not need to follow you down the page the
   way a pending update does. */
.errbar {
  background: var(--bad-bg); color: var(--ink);
  border-bottom: 1px solid var(--bad);
  padding: 9px 14px;
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  font-size: var(--fs-body);
}
.errbar .ic { width: 18px; height: 18px; flex: none; color: var(--bad); }
.errbar-text { flex: 1; min-width: 0; font-weight: 600; }
.errbar button {
  flex: none; min-height: 32px; padding: 0 12px;
  border-radius: var(--r-sm); font-size: var(--fs-body); cursor: pointer;
}

main {
  /* THE CLEARANCE IS GONE, AND THAT IS THE POINT.

     This used to reserve `--tabh + --safe-b + 34px` at the bottom, because
     a fixed bar floats over the content and the last card kept ending up
     underneath it. The bar is now a sibling row below this one, so it
     occupies its own space and there is nothing to reserve. Padding here
     is just the breathing room under the last card.

     `flex: 1` makes this the row that absorbs the slack: on a panel with
     little content it stretches, which is what puts the bar at the bottom
     of the screen instead of immediately under the last card. */
  flex: 1 1 auto;
  padding: 14px 14px 24px;
  /* `margin: 0 auto` still centres it: a column flex container stretches
     items across the cross axis, and `max-width` plus auto side margins
     wins over stretch. */
  max-width: 760px; margin: 0 auto; width: 100%;
}

/* THE BAR'S HEIGHT IS DECLARED, NOT DERIVED.

   The whole bar was growing slightly when the Comments tab was selected.
   Note that it cannot have been the button: `align-items` defaults to
   `stretch`, so every tab is already the height of the tallest one
   whichever is selected, and the selected-state rule below only sets
   colours. So the height was coming from this element - a flex container
   with no height of its own, sized by its content plus an `env()` inset
   that the platform is free to re-report.

   Both halves are now fixed: `--tabh` for the row (see the button rule)
   and an explicit box-sizing'd height here, so the bar measures the same
   whatever is selected and whatever the content does. `align-items:
   center` is stated rather than left to default, because relying on
   stretch to equalise the tabs was the thing that made a one-pixel
   difference in one label everybody's problem.

   The safe-area inset stays in the height rather than being dropped: on a
   phone with a home indicator the bar genuinely does need to be taller,
   and hard-coding past it puts the labels under the indicator.

   AND THE HEIGHT WAS NEVER THE BUG. Three fixes were aimed at this rule
   before anyone measured the device, and the measurement showed every term
   in it holding steady - 56px row, 25px inset - while the bar still
   appeared to grow. It was moving, not growing. See the shell comment
   above `body` for what actually changed and why the bar is no longer
   positioned by the platform at all.

   The height stays declared regardless. It was right for its own reasons
   (a tab bar has no business being sized by its longest label) and those
   reasons did not stop being true.

   `sticky` rather than `fixed`: the bar is a real row in the column, so on
   a short page it is already at the bottom and sticky does nothing. Once
   the page is long enough to scroll, sticky pins it to the bottom of the
   viewport - and because its containing block is `body`, which spans the
   whole document, it stays pinned the entire way down rather than being
   released early. */
nav.tabs {
  position: sticky; bottom: 0; z-index: 50;
  display: flex; align-items: center; background: var(--bg);
  border-top: 1px solid var(--line-2);
  box-shadow: 0 -1px 12px rgba(84, 84, 84, .08);
  box-sizing: border-box;
  height: calc(var(--tabh) + var(--safe-b));
  padding-bottom: var(--safe-b);
}
/* EVERY TAB IS EXACTLY THE SAME HEIGHT, IN EVERY STATE.

   The Comments tab was coming out slightly taller than Project and Docs
   when it was the selected one. Nothing in the selected-state rule below
   changes size - it sets three colours - and nothing writes into the
   button, so the extra height was emergent: the height was CONTENT-driven
   (`min-height` plus an icon, a gap, a line of text, padding and a
   border), which means anything that changes the text line box by a
   fraction of a pixel changes the height of that one button and therefore
   of the bar. A font fallback measuring differently for one label, or a
   label one pixel from wrapping, is enough.

   Rather than chase which of those it was, the row is now a fixed height
   and the label can never wrap. A tab bar has no business being sized by
   its longest word, and this cannot come back for a new reason when
   someone adds a fourth tab or a longer label.

   `line-height` is set explicitly rather than inherited, for the same
   reason: 1.45 on body is a decision about paragraphs, and a tab label
   should not silently resize when that is retuned. */
nav.tabs button {
  flex: 1 1 0; min-width: 0;
  height: var(--tabh);
  border: 0; background: none; color: var(--ink-2);
  font: inherit; font-size: var(--fs-cap); font-weight: 600; line-height: 1.2;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 3px; padding: 6px 2px; cursor: pointer;
  border-top: 0;
  white-space: nowrap; overflow: hidden;
  transition: color .18s var(--ease-out);
}
/* The selection indicator is a pill behind the icon only. Padding on the
   outer <svg> enlarges its box without scaling the drawing, so the pill
   and the icon share one element and cannot drift apart. */
nav.tabs button .ic {
  width: 22px; height: 22px; box-sizing: content-box;
  padding: 2px 16px; border-radius: 999px;
  transition: background-color .2s var(--ease-out), transform .12s var(--ease-out);
}
nav.tabs button:active .ic { transform: scale(.92); }
nav.tabs button[aria-selected="true"] .ic { background: var(--accent-lo); }
nav.tabs button[aria-selected="true"] {
  color: var(--accent);
}

section.panel { display: none; }
/* The clip lives here, not on <body> - see the note above `body`. `clip`
   rather than `hidden` so the panel does not become a scroll container,
   and `overflow-x` alone with `overflow-y` left visible, which is the
   pairing `clip` exists for.

   The overlay panels are excluded: they are full-bleed work surfaces that
   already manage their own clipping (`.anstage`, `.vwstage`), and they
   hold absolutely positioned children whose whole job is to sit over the
   picture. */
section.panel.active { display: block; }
/* A panel fades in when shown; display:none -> block restarts it on every
   navigation. OPACITY ONLY: a transform would make the panel the
   containing block for its fixed descendants (the comment sheet, the
   prompts). The work surfaces (.viewer) are excluded. */
@keyframes panelin { from { opacity: 0; } }
section.panel.active:not(.viewer) { animation: panelin .18s var(--ease-out); }
@media (prefers-reduced-motion: reduce) {
  section.panel.active:not(.viewer) { animation: none; }
}
section.panel:not(.viewer) { overflow-x: clip; }

/* ---------- cards, fields ---------- */

/* Elevation, not a rule, separates a card from the page. The border is
   kept at the surface colour so a card's box is identical in size to the
   bordered version and nothing downstream reflows. */
.card {
  background: var(--bg); border: 1px solid var(--bg);
  border-radius: var(--radius); padding: 16px; margin: 0 0 14px;
  box-shadow: var(--shadow-1);
}
.card > h2 {
  margin: -2px 0 12px; font-family: var(--font-head); font-size: var(--fs-title); font-weight: 600;
  letter-spacing: 0; color: var(--ink);
}
.card > h2 .sub { font-family: var(--font); letter-spacing: 0; font-weight: 400; font-size: var(--fs-body); color: var(--ink-2); }

details.card > summary {
  cursor: pointer; font-family: var(--font-head); font-size: var(--fs-title); font-weight: 600;
  letter-spacing: 0; color: var(--ink);
  min-height: var(--tap); display: flex; align-items: center;
  margin: -8px 0; padding-right: 30px; position: relative;
  list-style: none; -webkit-user-select: none; user-select: none;
  /* Reference summaries lead with the NEC citation and follow with a
     .sub description, which makes them two flex items rather than one
     text node. Without wrap they would overflow the card on a phone
     instead of breaking; column-gap restores the space that flex strips
     from between the two items. */
  flex-wrap: wrap; column-gap: .4em;
}
details.card > summary .sub { font-family: var(--font); letter-spacing: 0; font-weight: 400; font-size: var(--fs-body); color: var(--ink-2); }
details.card[open] > summary { margin-bottom: 4px; }
/* One chevron on every platform, on the right. The native marker differs
   by browser and Safari still draws it; both spellings are removed. */
details.card > summary::-webkit-details-marker { display: none; }
details.card > summary::marker { content: ''; }
details.card > summary::after {
  content: ''; position: absolute; right: 6px; top: 50%;
  width: 8px; height: 8px; margin-top: -6px;
  border-right: 2px solid var(--ink-2); border-bottom: 2px solid var(--ink-2);
  transform: rotate(45deg);
  transition: transform .18s var(--ease-out), margin-top .18s var(--ease-out);
}
details.card[open] > summary::after { transform: rotate(-135deg); margin-top: -2px; }
details.card > summary:active { color: var(--accent); }

.grid { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }
.grid.one { grid-template-columns: 1fr; }
.grid.three { grid-template-columns: 1fr 1fr 1fr; }
@media (max-width: 420px) { .grid.three { grid-template-columns: 1fr 1fr; } }

label.f { display: block; }
label.f > span {
  display: block; font-size: var(--fs-note); font-weight: 600;
  color: var(--ink-2); margin-bottom: 4px;
}
label.f > span .u { font-weight: 400; opacity: .8; }

input[type="number"], input[type="text"], select {
  width: 100%; min-height: var(--tap);
  font: inherit; font-size: var(--fs-title);
  padding: 8px 10px;
  border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--field); color: var(--ink);
  font-variant-numeric: tabular-nums;
}
/* Date fields match the text fields. Separate rule so the one above stays
   as the tests anchor it. iOS collapses an empty date input without the
   explicit height, and centres its value without the text-align. */
input[type="date"] {
  display: block; width: 100%; min-height: var(--tap);
  font: inherit; font-size: var(--fs-title); padding: 8px 10px;
  border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--field); color: var(--ink);
  -webkit-appearance: none; appearance: none; text-align: left;
}
input:focus, select:focus {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-lo);
}

/* A TEXTAREA IN A FORM FIELD IS AN INPUT THAT WRAPS.

   Inputs and selects are styled by element above, so every one of them in
   the app is full width without anyone thinking about it. Textareas were
   not, and each of the three that existed grew its own near-identical
   rule - .vwsheet-body textarea, .cm-body textarea, .antext textarea. The
   fourth, the manual comment box on the Comments tab, was added without
   one and fell back to the UA default: sized by the `cols` attribute,
   about twenty characters, stopping well short of the card edge. Nothing
   looked broken in the markup, because the markup is identical to the
   field beside it.

   NOT a bare `textarea` selector, which is what would match the inputs
   above. The annotator's label editor is a textarea too, absolutely
   positioned over the photo with its width, height and font size computed
   in JS to match the wrapped SVG that replaces it - a base width,
   min-height or font-size would fight metrics that have to agree to the
   pixel. Scoping to a form field excludes it by construction rather than
   by an exception someone has to remember.

   The three rules above still win where they apply: same specificity,
   declared later. */
label.f > textarea {
  width: 100%; font: inherit; font-size: var(--fs-lead);
  padding: 8px 10px;
  border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); color: var(--ink);
  resize: vertical;
}
label.f > textarea:focus {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-lo);
}
select { -webkit-appearance: none; appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--ink-2) 50%),
                    linear-gradient(135deg, var(--ink-2) 50%, transparent 50%);
  background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%;
  background-size: 5px 5px, 5px 5px; background-repeat: no-repeat;
  padding-right: 34px;
}

.seg { display: flex; border: 1.5px solid var(--line); border-radius: var(--r-sm); overflow: hidden; }
.seg button {
  flex: 1; min-height: var(--tap); border: 0; background: var(--bg);
  color: var(--ink-2); font: inherit; font-size: var(--fs-field); font-weight: 600;
  cursor: pointer; border-right: 1px solid var(--line);
}
.seg button:last-child { border-right: 0; }
.seg button[aria-pressed="true"] { background: var(--accent-fill); color: var(--on-accent); }

/* A segment that says what it would DO, under its own label - the page
   count and the file size for an export scope. Stacked rather than
   appended, because "Full set 38 sheets, about 90 MB" on one line either
   wraps into the next segment or truncates the number that matters.

   AN EXPLICIT CLASS, not `button:has(.segsub)`. This is load-bearing
   layout, and a selector the engine does not understand takes the whole
   rule with it - `:has()` is fine on current Safari and Chrome but this
   app is installed on phones that update when their owner lets them. The
   decorative rule below is allowed to depend on it; a two-line button
   collapsing to one is not a risk worth taking for a shorter selector. */
/* `display: flex` IS THE LOAD-BEARING LINE. `.seg` makes these buttons
   flex ITEMS; it does not make them flex containers, and a <button> is
   inline-block by default - so `flex-direction` and `gap` below applied to
   nothing and the sub-line ran on inline after the label. */
.seg.stacked button {
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 1px; line-height: 1.15;
  padding: 6px 8px; min-height: 54px;
}
.segsub { font-size: var(--fs-small); font-weight: 400; opacity: .78;
  font-variant-numeric: tabular-nums; }
.segsub:empty { display: none; }

/* A LIST OF INDEPENDENT TOGGLES - which plan sets an export covers.
   Real checkboxes, not a second `.seg`: these are not mutually exclusive,
   and a segmented control that allows several selections is a control
   that lies about what it is. Sized for a thumb, and the whole row is the
   label so the tap target is the row rather than the 20px box. */
.checklist { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
.checklist label {
  display: flex; align-items: center; gap: 10px;
  min-height: var(--tap); padding: 4px 10px;
  border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); font-size: var(--fs-field); cursor: pointer;
}
.checklist label:has(input:checked) { border-color: var(--accent); background: var(--accent-lo); }
.checklist input[type="checkbox"] { width: 22px; height: 22px; flex: none; accent-color: var(--accent); }
.checklist .k { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Shrinkable, not `flex: none`. A value long enough to need the room took
   it from the label instead and the two ran over each other on a phone. */
.checklist .v { font-size: var(--fs-small); color: var(--ink-2); flex: 0 1 auto;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-variant-numeric: tabular-nums; }

/* KEYBOARD SHORTCUT ROWS - a tool name and the one key bound to it.
   The field takes a key press rather than typed text, so it is narrow
   and centred like a keycap. */
.keylist { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
/* Compact rows: a keyboard is only at a desk, so these are not sized as
   thumb targets the way the rest of Settings is. */
.keylist label {
  display: flex; align-items: center; gap: 10px;
  min-height: 34px; padding: 2px 3px 2px 10px;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); font-size: var(--fs-body);
}
.keylist .k { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.keylist input {
  flex: none; width: 44px; min-height: 28px; height: 28px; padding: 0;
  border-width: 1px; border-radius: var(--r-xs);
  text-align: center; text-transform: uppercase;
  font-family: var(--mono); font-size: var(--fs-body); caret-color: transparent;
}
.keylist input:focus { border-color: var(--accent); }
.keylist-head { margin: 12px 0 0; font-size: var(--fs-note); font-weight: 600; color: var(--ink-2); }
.keylist .keyfixed {
  display: flex; align-items: center; gap: 10px;
  min-height: 30px; padding: 2px 10px;
  border: 1px solid var(--line-2); border-radius: var(--r-sm);
  background: var(--bg-2); font-size: var(--fs-note);
}
/* A long description wraps rather than losing its end on a phone. */
.keylist .keyfixed .k { white-space: normal; }
.keylist .keyfixed kbd {
  flex: none; font-family: var(--mono); font-size: var(--fs-note); color: var(--ink-2); white-space: nowrap;
}

/* =====================================================================
   A COLLAPSIBLE PICK LIST - which plan sets, which visits

   A `<details>` rather than a button toggling a class, for the reason the
   sheet index's plan sets are: the element carries its own open state, so
   it survives the list inside it being rebuilt - which renderExport does
   on every tick of the card - without anything having to remember it.

   THE SUMMARY SAYS HOW MANY ARE SELECTED, so a closed list is still
   answering the question it asks. Collapsed by default: on a phone two
   open lists were most of the Export card, and the summary line that says
   what you are about to get was below both of them.

   The caret is drawn here rather than left to the platform, for the same
   reason as .sh-setsum: the native triangle differs on every OS and sits
   at a different distance from the text on each, which reads as a
   misaligned row rather than as a control.
   ===================================================================== */
.picklist { margin-top: 10px; border: 1.5px solid var(--line); border-radius: var(--r-sm); }
.picklist > summary {
  display: flex; align-items: center; gap: 8px;
  min-height: var(--tap); padding: 4px 12px 4px 28px;
  cursor: pointer; position: relative;
  font-size: var(--fs-body); font-weight: 600;
  list-style: none;
  /* A tap target on glass must not select on a long press. */
  -webkit-user-select: none; user-select: none;
}
/* Both marker spellings - Safari still wants the -webkit- one. */
.picklist > summary::-webkit-details-marker { display: none; }
.picklist > summary::marker { content: ''; }
.picklist > summary::before {
  content: ''; position: absolute; left: 11px; top: 50%;
  width: 7px; height: 7px; margin-top: -5px;
  border-right: 2px solid var(--ink-2);
  border-bottom: 2px solid var(--ink-2);
  transform: rotate(-45deg);
  transition: transform .12s ease;
}
.picklist[open] > summary::before { transform: rotate(45deg); margin-top: -4px; }
.picklist[open] > summary { color: var(--accent); border-bottom: 1px solid var(--line); }
.picklist > summary:active { background: var(--accent-lo); }
.picklist-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
/* How many are ticked, so the closed row still answers its own question.
   Shrinkable for the same reason `.checklist .v` is - a value long enough
   to need the room must not take it from the label. */
.picklist-n { flex: 0 1 auto; min-width: 0; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap;
  font-size: var(--fs-small); font-weight: 500; color: var(--ink-2);
  font-variant-numeric: tabular-nums; }
/* Select all sits above the list rather than in the summary: a button
   inside a <summary> is a control inside a control, and the tap that
   means "all" would also toggle the list open or shut. */
.picklist-bar { display: flex; gap: 8px; padding: 8px 10px 0; }
.picklist .checklist { margin: 8px 10px 10px; }

/* Not element-qualified. A `label.btn` pointing at a hidden file input is
   a legitimate button, and qualifying these selectors as `button.btn` meant
   it silently rendered as plain text - which is exactly what happened to
   the import control. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  min-height: var(--tap); padding: 0 16px;
  border: 1.5px solid var(--accent-fill); border-radius: var(--r-md);
  background: var(--accent-fill); color: var(--on-accent);
  font: inherit; font-size: var(--fs-lead); font-weight: 600; cursor: pointer;
  box-shadow: 0 1px 2px rgba(78, 94, 120, .22);
  transition: transform .12s var(--ease-out), filter .12s var(--ease-out),
              box-shadow .12s var(--ease-out);
}
/* Press feedback: the control settles under the finger. Transform on a
   button establishes a containing block only for its own descendants,
   none of which are fixed. */
.btn:active:not(:disabled) { transform: scale(.97); filter: brightness(.94); box-shadow: none; }
.btn.ghost { background: var(--bg); color: var(--accent); border-color: var(--accent); box-shadow: none; }
.btn.mini { min-height: 38px; font-size: var(--fs-body); padding: 0 10px; }

/* ---------- results ---------- */

.out { background: var(--bg-2); border-radius: var(--r-md); padding: 4px 0; margin-top: 12px; }
.row {
  display: flex; justify-content: space-between; align-items: baseline;
  gap: 10px; padding: 9px 14px; border-bottom: 1px solid var(--line-2);
}
.row:last-child { border-bottom: 0; }
.row .k { font-size: var(--fs-body); color: var(--ink-2); }
.row .v { font-size: var(--fs-title); font-weight: 600; letter-spacing: -.005em;
          font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }

/* The governing result: label above, the number set large, the unit set
   smaller beside it (ui.js heroValue wraps the unit in .u). */
.row.hero {
  flex-direction: column; align-items: flex-start; gap: 2px;
  background: var(--accent-lo); border-radius: var(--r-md);
  margin: 6px 6px; padding: 12px 14px; border: 0;
}
.row.hero .k {
  color: var(--accent); font-weight: 600; font-size: var(--fs-note);
}
.row.hero .v {
  font-family: var(--font-head); font-size: var(--fs-hero); font-weight: 900; line-height: 1.15; letter-spacing: 0;
  color: var(--ink); text-align: left; white-space: normal;
}
.row.hero .v .u {
  font-family: var(--font);
  font-size: var(--fs-field); font-weight: 600; letter-spacing: 0; color: var(--ink-2);
}

/* A value that changed on the last recompute is briefly highlighted, so the
   eye finds what an input moved. */
@keyframes valchange {
  from { background-color: var(--accent-lo); }
  to   { background-color: transparent; }
}
@keyframes herochange {
  from { opacity: .35; transform: translateY(3px); }
  to   { opacity: 1;   transform: none; }
}
.row.chg:not(.hero) { animation: valchange .7s var(--ease-out); }
.row.hero.chg .v { animation: herochange .28s var(--ease-out); }
@media (prefers-reduced-motion: reduce) {
  .row.chg:not(.hero), .row.hero.chg .v { animation: none; }
}

.note {
  margin-top: 10px; padding: 9px 12px; border-radius: var(--r-sm);
  font-size: var(--fs-body); line-height: 1.4;
  background: var(--warn-bg); color: var(--warn);
  border-left: 4px solid currentColor;
}
/* Named explicitly even though the base `.note` already IS this, so that
   `note('warn', ...)` in the JS resolves to something a reader can find
   by grepping. The three below are the departures from it. */
.note.warn { background: var(--warn-bg); color: var(--warn); }
.note.bad { background: var(--bad-bg); color: var(--bad); }
.note.ok  { background: var(--ok-bg);  color: var(--ok); }
.note.info{ background: var(--accent-lo); color: var(--accent); }
.note + .note { margin-top: 6px; }

table.tbl { width: 100%; border-collapse: collapse; font-size: var(--fs-body); margin-top: 10px; }
table.tbl th, table.tbl td {
  padding: 7px 6px; text-align: right; border-bottom: 1px solid var(--line-2);
  font-variant-numeric: tabular-nums;
}
table.tbl th { font-size: var(--fs-small); font-weight: 600; letter-spacing: 0;
               color: var(--ink-2); text-align: right; }
table.tbl th:first-child, table.tbl td:first-child { text-align: left; }
table.tbl tr.pick { background: var(--ok-bg); }
table.tbl tr.pick td { font-weight: 700; color: var(--ok); }
table.tbl td.no { color: var(--ink-2); opacity: .55; }

/* dynamic fitting rows */
.frow { display: grid; grid-template-columns: 1fr 68px 44px; gap: 6px;
        align-items: center; margin-bottom: 6px; }
.frow select, .frow input { min-height: 42px; font-size: var(--fs-field); }
.frow button {
  min-height: 42px; border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); color: var(--bad); font: inherit; font-size: var(--fs-head);
  line-height: 1; cursor: pointer;
}

/* fixture schedule rows - label on the left, small qty box on the right, so a
   long fixture list stays scannable one-handed and the tap target stays big */
.fixrow {
  display: grid; grid-template-columns: 1fr 84px; gap: 8px;
  align-items: center; padding: 6px 0;
  border-bottom: 1px solid var(--line-2);
}
.fixrow:last-child { border-bottom: 0; }
.fixrow > .fixlabel { font-size: var(--fs-body); line-height: 1.3; }
.fixrow > .fixlabel .fixmark { margin-left: 2px; color: var(--ink-2); font-weight: 700; }
.fixnote { margin-top: 12px; }
.fixrow input { min-height: 42px; font-size: var(--fs-lead); text-align: right; }
.fixrow.has-qty { background: var(--accent-lo); border-radius: var(--r-sm);
  padding: 6px 8px; border-bottom-color: transparent; }

/* One fixture schedule with a header per section. */
.fixsec + .fixsec { margin-top: 14px; }
.fixhead {
  margin: 0 0 2px; padding-bottom: 4px; border-bottom: 1.5px solid var(--line);
  font-family: var(--font-head); font-size: var(--fs-body); font-weight: 600; color: var(--ink);
}
/* A locked tab shows only the fixtures it counts. */
.fixsched.hide-zero .fixrow:not(.has-qty),
.fixsched.hide-zero .fixsec:not(.has-qty) { display: none; }
.fixsched.hide-zero .fixsec + .fixsec { margin-top: 8px; }

/* The summary: water sizes, then sewer size by slope. Side by side once
   there is room. */
.hcsum { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
.hcsum .out { margin-top: 0; }
.hcsum .sumsub { display: block; font-size: var(--fs-small); color: var(--ink-2); }
.hcsum .sumsplit { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.hcsum .sumsplit > .row { border-bottom: 0; }
.hcsum .sumsplit > .row + .row { border-left: 1px solid var(--line-2); }
.hcsum .sumtbl td { font-size: var(--fs-title); font-weight: 600; }
.hcsum .sumtbl tbody th { font-size: var(--fs-body); }
@media (min-width: 640px) {
  .hcsum { grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); align-items: start; }
}

/* Reference tables nested in one disclosure card. */
.reftbl { border-top: 1px solid var(--line-2); padding: 4px 0; }
.reftbl:first-child { border-top: 0; }
.reftbl > summary {
  cursor: pointer; min-height: var(--tap); display: flex; align-items: center;
  font-weight: 600; color: var(--ink);
}
.reftbl > .hint { margin-bottom: 6px; }
.reftbl th { white-space: nowrap; }

/* ---------------------------------------------------------------------
   Project tools: the tool header and its tab strip
   --------------------------------------------------------------------- */

/* The tool's name and its Duplicate button on one line. `gap` rather
   than a margin on the button, so the heading can wrap on a narrow phone
   without the button climbing onto it. */
.toolhead {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; flex-wrap: wrap;
}
.toolhead h2 { margin: 0; }
/* The two buttons stay together as the heading wraps, and wrap as a pair
   when the pair itself no longer fits beside it. */
.toolhead-btns { display: flex; gap: 6px; flex-wrap: wrap; }

/* A LOCKED TAB SAYS SO WHEREVER IT IS SEEN, not only on the button that
   set it: the chip carries the padlock too, so a locked tab is
   recognisable from the strip without selecting it. Sized only - the
   flex row above is what puts it beside the name. */
.tabstrip .tabchip .ic { width: 15px; height: 15px; }
.btn.ghost.locked { border-color: var(--accent); color: var(--accent); }

/* Disabled fields must read as deliberately closed rather than broken.
   The UA's own greying is nearly invisible on the fixture rows, whose
   inputs already sit on a tinted row when they carry a quantity. */
#p-proj-tool input:disabled, #p-proj-tool select:disabled {
  opacity: .6; background: var(--bg-2); cursor: default;
}

/* SCROLLS SIDEWAYS, DOES NOT WRAP. A wrapping strip changes height as
   tabs are added, which moves every card below it - on a phone that
   means the fixture list jumps under the thumb mid-entry. Overflow is
   contained here rather than by the panel, the same rule .tblwrap
   follows.

   ITS HEIGHT IS DECLARED, and it has to be. A horizontal scrollbar is
   laid out INSIDE the content box on every desktop engine that draws a
   classic one, so a strip sized by its chips loses the scrollbar's
   height off the bottom of those chips the moment there are enough tabs
   to scroll - which is exactly what duplicating to the point of
   scrolling produces, and why nothing is wrong until then. The room is
   reserved rather than discovered: `--tabstrip-gut` is both the bottom
   padding and the difference between the chip's height and the strip's,
   so the two cannot drift.

   `scrollbar-gutter: stable` reserves it on engines that honour it;
   the padding covers the ones that do not, and on a phone - where the
   bar is an overlay and takes no space at all - it is a few pixels of
   breathing room under the strip. */
.tabstrip {
  --tabchip-h: 40px;
  --tabstrip-gut: 10px;
  display: flex; align-items: center; gap: 6px; margin-top: 10px;
  overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch;
  scrollbar-width: thin; scrollbar-gutter: stable;
  padding-bottom: var(--tabstrip-gut);
  min-height: calc(var(--tabchip-h) + var(--tabstrip-gut));
}
/* `flex` row, not inline text. `svg.ic` is `display: block` - the tab
   bar needs it, since an icon over a label is a column - so an icon
   dropped inside a chip took a line of its own and pushed the tab name
   underneath it. `vertical-align` cannot reach a block box; a flex row
   puts the padlock and the name on one line by construction. */
.tabstrip .tabchip {
  flex: none; height: var(--tabchip-h); padding: 0 14px;
  display: inline-flex; align-items: center; gap: 5px;
  border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); color: var(--ink-2);
  font: inherit; font-size: var(--fs-field); cursor: pointer;
  max-width: 200px; white-space: nowrap;
}
/* The NAME truncates, not the chip: an overflow on the chip itself would
   clip the padlock away, which is the one part that must survive. */
.tabstrip .tabchip .tabchip-t {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tabstrip .tabchip[aria-selected="true"] {
  border-color: var(--accent); background: var(--accent-lo);
  color: var(--accent); font-weight: 700;
}

/* ---------------------------------------------------------------------
   Roof drain sizing: the drawing stage
   --------------------------------------------------------------------- */

/* The stage fills its card, so the card gives up its own padding for
   the stage's row and keeps it for everything else.

   DESCENDANTS, NOT CHILDREN. These were `>` selectors, which stopped
   matching the moment the toolbar and the stage were wrapped in
   `#rd-workspace` for the full-screen re-parenting - so the toolbar lost
   its side margins and sat flush against the card edge while every
   other card kept its padding. A child selector is a dependency on a
   nesting depth, and this markup has two depths by design. */
.card.cardflush { padding-left: 0; padding-right: 0; }
.card.cardflush .rdbar,
.card.cardflush .rdgroups,
.card.cardflush > .hint,
.card.cardflush #rd-workspace > .hint { margin-left: 14px; margin-right: 14px; }
/* In full screen the host supplies the padding, so the inset would be
   doubled. */
.rdfull .rdbar, .rdfull .rdgroups, .rdfull .hint { margin-left: 0; margin-right: 0; }

/* ABOVE the drawing, never floating over it. A toolbar on top of a plan
   covers the one part of the screen somebody is aiming at, and on a
   phone that is most of it. */
.rdbar {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-bottom: 8px;
}
.rdbar .seg { flex: none; }
/* FULL-SIZE TARGETS. `.seg button` is sized for a settings card, where
   it sits under a label and is read before it is pressed. These are the
   modes of a drawing tool: they are switched constantly, mid-task, and
   often with the other hand on the mouse - so they get a real height
   and room either side of the word. */
.rdseg button {
  min-height: var(--tap, 44px); padding: 0 16px;
  font-size: var(--fs-field); font-weight: 600;
}
/* Two segmented controls plus six buttons is more bar than a phone has,
   so the shape toggle's labels shorten before anything wraps. */
@media (max-width: 560px) {
  .rdseg button { padding: 0 11px; font-size: var(--fs-body); }
}

/* A DECLARED HEIGHT, not one the canvas decides. A canvas with no CSS
   height falls back to its 150px attribute default, and one sized from
   its own `height` attribute would grow every time the renderer set
   that attribute for the device pixel ratio - a feedback loop that
   reads as the drawing slowly filling the page. `dvh` so the browser
   chrome collapsing does not resize it mid-drag. */
.rdstage {
  position: relative;
  width: 100%;
  height: 58vh;            /* fallback first, for anything without dvh */
  height: 58dvh;
  min-height: 280px;
  background: var(--bg-2);
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  overflow: hidden;
  /* The stage owns its gestures. Without this a drag pans the page out
     from under the drawing on every touch device. */
  touch-action: none;
}
.rdstage canvas { display: block; width: 100%; height: 100%; cursor: crosshair; }

/* The zoom cluster wraps as one unit or not at all. */
.rdzoomgrp { display: flex; align-items: center; gap: 6px; flex: none; }
/* Tabular, so stepping 100% / 125% / 160% does not shuffle the buttons
   either side of it. */
.rdzoom {
  flex: none; min-width: 48px; text-align: center;
  font-size: var(--fs-note); font-variant-numeric: tabular-nums; color: var(--ink-2);
}

/* The selected wall's controls, over the stage rather than under it:
   flipping a side is judged by watching the ticks move, so the control
   has to be in sight of them. Bottom left, clear of the label a small
   area draws at its centre. */
.rdwallbar {
  position: absolute; left: 10px; bottom: 10px; z-index: 2;
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  max-width: calc(100% - 20px);
  padding: 8px 10px; border-radius: var(--r-md);
  background: var(--bg); border: 1.5px solid var(--line);
  box-shadow: 0 4px 14px rgba(0,0,0,.18);
}
.rdwallbar-t { font-size: var(--fs-note); color: var(--ink-2); }

/* The height field, inline in the bar. A `label.f` would stack its text
   above the input and make the bar two rows tall over the drawing. */
.rdwallbar-h { display: inline-flex; align-items: center; gap: 6px; margin: 0; }
.rdwallbar-h > span { font-size: var(--fs-note); color: var(--ink-2); }
.rdwallbar-h input {
  width: 76px; min-height: 36px; text-align: right;
  /* The base `input` rule sizes for a form card; in a floating bar over
     a drawing that is most of the bar. */
  font-size: var(--fs-field); padding: 0 8px; margin: 0;
}

/* The slope column the analysis labels size against. Inline in the
   toolbar, so it does not push the bar's height around. */
.rdslope { flex: none; margin: 0; }
.rdslope > span { display: none; }
.rdslope select { min-height: var(--tap, 44px); }

/* THE GROUP RAIL, between the toolbar and the drawing. Above the plan
   rather than over it: it is read and clicked constantly while
   assigning, and a floating rail would cover the areas being pointed
   at. */
.rdgroups {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-bottom: 8px;
}
.rdgroups-t {
  flex: none; font-size: var(--fs-small); font-weight: 700; color: var(--ink-2);
}
.rdgroups-chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
  position: relative; }

/* THE CHIP IS A WRAPPER, NOT A BUTTON. It holds the select button and,
   on the active group, the edit button beside it - and a button cannot
   contain another button, so nesting them would be markup the browser
   silently reshapes until the inner one stops receiving clicks. The
   border and the pressed state live out here so the pair reads as one
   control. */
.rdchip {
  display: inline-flex; align-items: stretch; overflow: hidden;
  border: 1.5px solid var(--line); border-radius: 18px;
  background: var(--bg);
}
.rdchip.on {
  border-color: var(--chip, var(--accent));
  box-shadow: inset 0 0 0 1.5px var(--chip, var(--accent));
}
.rdchip-pick {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 36px; padding: 0 12px;
  border: 0; background: none; color: var(--ink); cursor: pointer;
  font: inherit; font-size: var(--fs-body); white-space: nowrap;
}
.rdchip.on .rdchip-pick { font-weight: 700; }
/* The dot carries the group's own colour, so the rail and the plan
   agree without the chip having to be tinted - a tinted chip against a
   tinted selected state is two meanings on one surface. */
.rdchip-dot {
  flex: none; width: 12px; height: 12px; border-radius: 50%;
  background: var(--chip, var(--accent));
}
/* On the right of the chip, with a rule between, so it reads as a
   second control rather than as part of the label. */
.rdchip-edit {
  flex: none; width: 34px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; border-left: 1px solid var(--line);
  background: none; color: var(--ink-2); cursor: pointer;
}
.rdchip-edit .ic { width: 17px; height: 17px; }
.rdchip-edit:active { background: var(--accent-lo); color: var(--accent); }

/* The rename/delete menu, anchored under its chip. Positioned against
   the rail, which is its offset parent, so the numbers stay right
   through a scroll. */
.rdmenu {
  position: absolute; z-index: 5; min-width: 168px;
  padding: 6px; border-radius: var(--r-md);
  background: var(--bg); border: 1.5px solid var(--line);
  box-shadow: 0 8px 22px rgba(0,0,0,.22);
}
.rdmenu-h {
  padding: 4px 8px 6px; font-size: var(--fs-small); font-weight: 700;
  color: var(--ink-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.rdmenu-b {
  display: block; width: 100%; text-align: left;
  min-height: var(--tap, 44px); padding: 0 8px;
  border: 0; border-radius: var(--r-sm); background: none;
  color: var(--ink); font: inherit; font-size: var(--fs-field); cursor: pointer;
}
.rdmenu-b:active { background: var(--accent-lo); }
.rdmenu-b.danger { color: var(--bad); }
.rdchip-none {
  font-size: var(--fs-note); color: var(--warn);
  background: var(--warn-bg); border-radius: var(--r-md); padding: 4px 9px;
}

/* The selection's answer. Full width along the bottom because it is one
   long line of sizes, and wrapping it into the corner would make it two
   or three. */
.rdselbar {
  position: absolute; left: 10px; right: 10px; bottom: 10px; z-index: 2;
  display: flex; align-items: center; gap: 10px;
  padding: 8px 10px; border-radius: var(--r-md);
  background: var(--bg); border: 1.5px solid var(--accent);
  box-shadow: 0 4px 14px rgba(0,0,0,.18);
}
.rdselbar-t {
  flex: 1; min-width: 0; font-size: var(--fs-note); font-variant-numeric: tabular-nums;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* The wall height question, over the drawing rather than under it.
   Centred near the top so it clears the selection bars in the bottom
   corner, and capped in width so it does not become a wall of dialog on
   a wide monitor. */
.rdask {
  position: absolute; z-index: 3;
  left: 50%; top: 14px; transform: translateX(-50%);
  width: min(420px, calc(100% - 24px));
  padding: 12px; border-radius: var(--r-md);
  background: var(--bg); border: 1.5px solid var(--accent);
  box-shadow: 0 8px 26px rgba(0,0,0,.28);
}
.rdask .hint { margin-bottom: 8px; }

/* FULL SCREEN. The workspace - toolbar and stage together - is moved in
   here, so the edit controls come with it and there is only ever one
   canvas. Fixed, and OUTSIDE <main> in the markup for the reason every
   overlay here is: a section.panel carries `overflow-x: clip`, which
   would make the panel the containing block for a fixed descendant. */
.rdfull {
  position: fixed; inset: 0; z-index: 90;
  background: var(--bg);
  display: flex; flex-direction: column;
  padding: calc(env(safe-area-inset-top, 0px) + 10px) 12px
           calc(env(safe-area-inset-bottom, 0px) + 10px);
  gap: 8px;
}
.rdfull #rd-workspace { display: flex; flex-direction: column; flex: 1; min-height: 0; gap: 8px; }
/* The stage gives up its declared height here and takes what is left of
   the screen instead - that is the whole point of going full screen. */
.rdfull .rdstage { flex: 1; height: auto; min-height: 0; }
/* The page behind must not scroll while a fixed layer is over it - on
   iOS a scroll there moves the page under the drawing. */
body.rd-fullopen { overflow: hidden; }

/* wide reference tables - horizontal scroll stays inside this box, never
   the page. max-width:100% keeps it from ever exceeding its column even
   before JS/layout settles; width:100% + display:block keep it a normal
   block box regardless of what its (table) child would prefer. */
.tblwrap {
  display: block; width: 100%; max-width: 100%;
  margin-top: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch;
}
.tblwrap table.tbl { margin-top: 0; }

/* marina capacity sensitivity grid - a conditional-formatting analog to the
   firm's Marina Load Calculator.xlsx "Demanded Load" sheet: each cell is
   colored by whether that qty50/qty30 combination's demand load is within
   the available feeder/service amps, with the current input outlined. */
table.captable th, table.captable td { padding: 6px 5px; font-size: var(--fs-note); }
table.captable td.cap-ok  { background: var(--ok-bg);  color: var(--ok); }
table.captable td.cap-bad { background: var(--bad-bg); color: var(--bad); font-weight: 700; }
table.captable td.cap-current { outline: 2px solid var(--accent); outline-offset: -2px; font-weight: 700; }

/* psych chart */
.chartwrap { margin-top: 12px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
svg.chart { display: block; width: 100%; min-width: 320px; height: auto; }
svg.chart .sat   { fill: none; stroke: var(--ink); stroke-width: 2.2; }
svg.chart .rh    { fill: none; stroke: var(--line); stroke-width: 1; }
svg.chart .rhlab { fill: var(--ink-2); font-size: var(--fs-tiny); font-family: var(--font); }
svg.chart .gridl { stroke: var(--line-2); stroke-width: 1; }
svg.chart .axis  { stroke: var(--ink-2); stroke-width: 1.2; }
svg.chart .axlab { fill: var(--ink-2); font-size: var(--fs-micro); font-family: var(--font); }
svg.chart .proc  { stroke: var(--accent); stroke-width: 2.5; fill: none;
                   stroke-linecap: round; }
svg.chart .pt1   { fill: var(--accent); stroke: var(--bg); stroke-width: 2; }
svg.chart .pt2   { fill: var(--bad); stroke: var(--bg); stroke-width: 2; }
svg.chart .ptlab { fill: var(--ink); font-size: var(--fs-micro); font-weight: 700;
                   font-family: var(--font); }

.basis h3 { font-size: var(--fs-body); margin: 16px 0 6px; }
.basis p, .basis li { font-size: var(--fs-body); color: var(--ink-2); line-height: 1.5; }
.basis ul { padding-left: 20px; margin: 6px 0; }
.basis code { font-family: var(--mono); font-size: var(--fs-note); background: var(--bg-2);
              padding: 1px 4px; border-radius: var(--r-xs); }
.stamp {
  border: 2px solid var(--bad); color: var(--bad); border-radius: var(--r-sm);
  padding: 10px 12px; font-size: var(--fs-body); font-weight: 600; line-height: 1.45;
}
.hint { font-size: var(--fs-note); color: var(--ink-2); margin-top: 8px; }
.stackbtns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }

/* An empty list: an icon in a tinted disc, a title, one line of guidance. */
.emptystate {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: 6px; padding: 28px 20px 26px;
}
.emptystate-ic {
  width: 26px; height: 26px; box-sizing: content-box; padding: 13px;
  border-radius: 50%; background: var(--accent-lo); color: var(--accent);
  margin-bottom: 4px;
}
.emptystate-title { font-family: var(--font-head); font-size: var(--fs-lead); font-weight: 600; color: var(--ink); }
.emptystate-text { font-size: var(--fs-body); line-height: 1.45; color: var(--ink-2); max-width: 34ch; }

/* The bar inside a progress note. */
progress.notebar {
  display: block; width: 100%; height: 6px; margin-top: 8px;
  -webkit-appearance: none; appearance: none;
  border: 0; border-radius: var(--r-xs); overflow: hidden;
  background: color-mix(in srgb, currentColor 18%, transparent);
  color: var(--accent);
}
progress.notebar::-webkit-progress-bar { background: transparent; }
progress.notebar::-webkit-progress-value {
  background: var(--accent); border-radius: var(--r-xs); transition: width .2s var(--ease-out);
}
progress.notebar::-moz-progress-bar { background: var(--accent); border-radius: var(--r-xs); }

/* ---------- home screen ---------- */
.home-card { padding-top: 18px; }
.home-hello {
  margin: 6px 2px 14px; font-family: var(--font-head); font-size: var(--fs-display); font-weight: 900;
  letter-spacing: 0; line-height: 1.2; color: var(--ink);
}
#home-recent .out { margin-top: 0; }

/* A card heading with a control on the right. The h2 matches .card > h2. */
.cardhead {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; margin: -2px 0 12px;
}
.cardhead h2 {
  margin: 0; font-family: var(--font-head); font-size: var(--fs-title); font-weight: 600;
  letter-spacing: 0; color: var(--ink);
}
.cardhead select { width: auto; min-width: 84px; flex: none; }
.pj-go { width: 18px; height: 18px; color: var(--ink-2); }
.home-grid {
  display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 4px;
}
@media (max-width: 460px) { .home-grid { grid-template-columns: 1fr; } }
.home-btn {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: 6px; padding: 26px 14px; min-height: 150px;
  border: 1.5px solid var(--tile-edge); border-radius: var(--radius);
  background: var(--tile-bg); color: var(--ink); cursor: pointer;
  font: inherit;
  box-shadow: var(--shadow-1);
  transition: transform .14s var(--ease-out), box-shadow .14s var(--ease-out),
              border-color .14s var(--ease-out), background-color .14s var(--ease-out);
}
.home-btn:active {
  background: var(--accent-lo); border-color: var(--accent);
  transform: scale(.98); box-shadow: none;
}
/* The icon sits in a tinted disc; padding on the outer <svg> sizes the disc
   without scaling the drawing. */
.home-btn .home-ic {
  width: 30px; height: 30px; stroke-width: 1.5; box-sizing: content-box;
  padding: 11px; border-radius: 50%;
  background: var(--accent-lo); color: var(--accent);
}
.home-btn .home-label { font-family: var(--font-head); font-size: var(--fs-title); font-weight: 600; }
.home-btn .home-desc { font-size: var(--fs-note); color: var(--ink-2); line-height: 1.4; }

/* ---------- site visit portal ---------- */

/* A project row is a button so it is reachable by keyboard and reads as
   tappable; it is styled to match .row so the list looks like every
   other output block in the app. */
.pj-row {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 11px 14px; border: 0; border-bottom: 1px solid var(--line-2);
  background: none; color: var(--ink); font: inherit; text-align: left;
  cursor: pointer; min-height: 54px;
  transition: background-color .14s var(--ease-out);
}
.pj-row:last-child { border-bottom: 0; }
.pj-row:active { background: var(--accent-lo); }
.pj-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.pj-name { font-size: var(--fs-lead); font-weight: 600; }
.pj-sub { font-size: var(--fs-note); color: var(--ink-2); }
.pj-count {
  flex: none; font-family: var(--mono); font-size: var(--fs-note); color: var(--ink-2);
}

/* A PROJECT ON THE SERVER THAT IS NOT ON THIS DEVICE.
   Its own row rather than the sheet index's, because the two behave
   differently: a sheet row IS a button - tapping it opens the sheet -
   and a cloud project cannot be opened at all until it has been added.
   So the name here is text and the action is a labelled button beside
   it. The row used to be one big button with a `+` on the end, which
   made reading the list and downloading a project the same gesture. */
.cloud-row {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 12px; border-bottom: 1px solid var(--line);
}
.cloud-row:last-child { border-bottom: 0; }
.cloud-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.cloud-name {
  font-size: var(--fs-field); font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cloud-meta { font-size: var(--fs-note); color: var(--ink-2); }
/* The button keeps its label on a narrow phone rather than being
   squeezed to an ellipsis - it is the only thing on the row that does
   anything, so it is the last thing that should shrink. */
.cloud-row .btn { flex: none; }
.cloud-row .flag {
  flex: none; font-size: var(--fs-cap); font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; color: var(--ink-2); background: var(--line);
  border-radius: var(--r-xs); padding: 2px 6px;
}

/* "not on the server" / "unsynced changes" on a project row.
   WARN, NOT BAD. Nothing is broken - the device simply holds work the
   server has not got - and colouring it like an error trains people to
   ignore the colour that means an error. It is also the reason a synced
   project shows NO badge at all: a column of green "synced" chips is a
   column nobody reads, and the one row that matters stops standing out.
   Its own rule rather than #sh-list's, which is scoped to the sheet
   index and uses the error palette deliberately. */
#pj-list .flag {
  align-self: flex-start;
  font-size: var(--fs-cap); font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; color: var(--warn); background: var(--warn-bg);
  border-radius: var(--r-xs); padding: 1px 5px; margin-top: 1px;
}

/* Sheet index rows: the number is the thing being scanned for, so it
   stays on its own line weight and the title stays quiet. */
#sh-list .k { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px; }
#sh-list .sh-t { color: var(--ink-2); font-size: var(--fs-note); }
#sh-list .flag {
  font-size: var(--fs-cap); font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; color: var(--bad); background: var(--bad-bg);
  border-radius: var(--r-xs); padding: 1px 5px; margin-left: 4px;
}

/* Import review: number, title, and how it was obtained. */
.shrow {
  display: grid; grid-template-columns: 104px 1fr 58px 28px; gap: 6px;
  align-items: center; padding: 6px 0; border-bottom: 1px solid var(--line);
}
.shrow:last-child { border-bottom: 0; }
.shrow input { min-height: 42px; font-size: var(--fs-field); }
.shtag {
  font-size: var(--fs-small); font-weight: 600;
  color: var(--ink-2); text-align: right;
}
@media (max-width: 420px) {
  .shrow { grid-template-columns: 92px 1fr 46px 28px; }
  .shrow input { font-size: var(--fs-body); }
}

/* =====================================================================
   THE IMPORT REVIEW: DOCKED CONFIRM, GROUP HEADERS, AND A ROW CUT

   The confirm controls used to sit under the list. On a 300-sheet set
   that is 300 rows of scrolling to reach "Add these sheets" and the same
   again to come back and correct something.

   FIXED, AND A SIBLING OF THE CARD. `position: fixed` inside an element
   with `overflow` set is not fixed at all - references/ui-and-layout.md
   carries that at length - so the dock lives outside `#sh-review` in the
   markup and is shown and hidden alongside it in JS.

   ABOVE THE TAB BAR, and the tab bar's own height plus the home-indicator
   inset is what it clears. Both are the declared constants rather than
   numbers guessed at here, for the reason that file gives: three fixes
   went at the tab bar's height before a measurement settled it.
   ===================================================================== */
.shdock {
  position: fixed; left: 0; right: 0;
  bottom: calc(var(--tabh) + var(--safe-b));
  z-index: 45;
  display: flex; flex-direction: column; gap: 6px;
  padding: 8px 12px 10px;
  background: var(--bg); border-top: 1px solid var(--line);
  box-shadow: 0 -6px 18px rgba(0,0,0,.14);
}
.shdock-row { display: flex; align-items: center; gap: 8px; }
/* The confirm row's buttons share the width; the trade row's button is
   sized by its label so the summary beside it gets the rest. */
.shdock-row:last-child .btn { flex: 1; }
.shdock-row:first-child .btn { flex: none; }
.shdock-count {
  flex: 1; min-width: 0;
  font-size: var(--fs-small); line-height: 1.3; color: var(--ink-2);
  /* Five trade names is longer than any phone is wide, and this must not
     push the button off the row: it truncates instead. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* The card has to clear the dock, or its last rows sit underneath it and
   cannot be reached - which is the bug the dock was added to fix, moved
   to the other end of the list. Two rows of dock plus its padding. The
   padding is on the card unconditionally: the dock is only ever visible
   while the card is, so there is no state in which this reserves space
   for something that is not there. */
#sh-review { padding-bottom: 116px; }

/* WHERE A DOCUMENT BEGINS. In the list rather than above it, so the cut
   is visible against the sheets either side of it - a header floating
   above an undivided list is a label pretending to be a boundary. */
.shgroup {
  display: flex; align-items: center; gap: 8px;
  margin: 14px 0 2px; padding: 6px 8px;
  background: var(--accent-lo); border-radius: var(--r-sm);
  border-left: 3px solid var(--accent);
}
.shgroup-name {
  flex: 1; min-width: 0; font-size: var(--fs-note); font-weight: 700;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.shgroup-count { flex: none; font-size: var(--fs-cap); color: var(--ink-2); }
.shgroup-x {
  flex: none; width: 30px; height: 30px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  border: 0; border-radius: var(--r-sm); background: none;
  color: var(--ink-2); cursor: pointer;
}
.shgroup-x .ic { width: 16px; height: 16px; }
.shgroup-x:active { background: rgba(0,0,0,.08); color: var(--ink); }

/* THE PER-ROW CUT GETS ITS OWN COLUMN, not an absolute position over the
   row. Floated to the right it landed on top of the source tag - `bkmk`,
   `title block` - which is the one thing on the row that says how much to
   trust the number beside it. So the grid gained a fourth column and the
   rows that cannot be cut append an empty span to hold it, keeping every
   row's columns aligned down the list.

   Deliberately quiet: it is on every row of a three-hundred-row list, and
   a control repeated that many times has to recede. */
.shrow-cut, .shrow-nocut {
  width: 28px; height: 28px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  border: 0; border-radius: var(--r-sm); background: none;
  color: var(--ink-2); opacity: .55; cursor: pointer;
}
.shrow-nocut { visibility: hidden; }
.shrow-cut .ic { width: 16px; height: 16px; }
.shrow-cut:active { opacity: 1; background: var(--accent-lo); color: var(--accent); }

/* A sheet a revision adds: where it goes in the issue, on its own line. */
.shrow-place {
  grid-column: 1 / -1; display: flex; align-items: center; gap: 8px;
}
.shrow-place .flag { flex: none; }
.shrow-place select { flex: 1 1 auto; min-width: 0; }

/* Buttons that are not <button>: a label pointing at a hidden file input.
   inline-flex so an icon and its text sit on the baseline together. */
.btn .ic { width: 18px; height: 18px; flex: none; }

/* The Project Portal sits in its own card, so its button spans the row. */
.home-grid.one { grid-template-columns: 1fr; }
.home-btn.wide {
  flex-direction: row; align-items: center; text-align: left;
  gap: 14px; padding: 18px 16px; min-height: 0;
}
.home-btn.wide .home-ic { flex: none; }
.home-btn.wide > span:not(.home-ic) { display: block; }

/* ---------- sheet viewer ---------- */

/* Full-bleed: on a phone every pixel of a 36x24 sheet is worth having,
   so the viewer escapes the card padding entirely. */
.panel.viewer.active {
  display: flex; flex-direction: column;
  position: fixed; inset: 0; z-index: 40;
  background: var(--bg-2);
}
.vwbar {
  display: flex; align-items: center; gap: 8px; flex: none;
  padding: 8px 10px; background: var(--navy); color: #fff;
  padding-top: max(8px, env(safe-area-inset-top));
}
.vwbtn {
  display: inline-flex; align-items: center; justify-content: center;
  flex: none; width: 36px; height: 36px; border: 0; border-radius: var(--r-sm);
  background: rgba(255,255,255,.16); color: #fff; cursor: pointer;
}
.vwbtn .ic { width: 20px; height: 20px; }
.vwbtn:active { background: rgba(255,255,255,.28); }
.vwbtn[aria-pressed="true"] { background: var(--accent-fill); color: var(--on-accent); }
.vwtitle { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.vwtitle strong { font-size: var(--fs-field); }
.vwtitle span {
  font-size: var(--fs-small); opacity: .75;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.vwzoom { font-family: var(--mono); font-size: var(--fs-small); opacity: .8; flex: none; }

/* THE VISIT FILTER, in the bar with the sheet number and the zoom.

   DRAWN AS A DROPDOWN, because the platform will not draw one. A native
   <select> on iOS Safari renders as plain text with no border and no
   chevron; sitting in a dark bar beside a title and a percentage it read
   as a label, and nobody tried to tap it. So the pill, the chevron and
   the pressed state are ours: `appearance: none` removes the platform's
   own decoration (there was none on iOS and a mismatched one elsewhere)
   and the chevron is a real icon in the markup rather than a background
   image, so it inherits the bar's colour like every other icon here.

   The <select> fills the pill, so a tap anywhere inside opens the list. */
/* DOCKED TO THE LEFT of the bar, beside Back and the thumbnails toggle,
   rather than to the right beside the zoom. The right-hand end of the
   top bar is where the account chip and the version now live, and the
   two bars read as one strip when the viewer opens over the app; keeping
   the walk selector opposite them stops the right-hand corner becoming
   the place every unrelated control ended up.

   `max-width` is still in vw and still truncates: the sheet number is
   what must not be pushed out of the bar, and it now sits to the RIGHT
   of this, so the truncation protects the same thing from the other
   direction. */
.vwfilter {
  position: relative; flex: none;
  display: flex; align-items: center; gap: 5px;
  max-width: 46vw; height: 30px; padding: 0 7px;
  border: 1px solid rgba(255,255,255,.32); border-radius: var(--r-sm);
  background: rgba(255,255,255,.16); color: #fff;
  font-size: var(--fs-note); cursor: pointer;
}
.vwfilter:active { background: rgba(255,255,255,.28); }
.vwfilter .ic { width: 15px; height: 15px; flex: none; opacity: .9; }
/* The revision pill on a sheet shown at an older revision, matching its frame. */
.vwfilter.rev-old { border-color: var(--rev-old); background: var(--rev-old); color: var(--rev-old-ink); }
.vwfilter-caret { width: 13px; height: 13px; }
/* The visible value truncates rather than pushing the sheet number out of
   the bar - the full text is still there in the open list.

   EVERY BOX-MAKING DECLARATION OFF THE BASE `select` RULE HAS TO BE
   UNDONE HERE, not just the ones that are visible at rest. That rule
   dresses a select as a form field: full width, `min-height: var(--tap)`
   (48px), a 1.5px border, an 8px radius - and `select:focus` adds
   `box-shadow: 0 0 0 3px var(--accent-lo)`. Inside a 30px pill that made
   a 48px invisible box which, the moment the control was tapped, lit up
   as a pale blue rectangle standing proud of the pill above and below it.
   It read as a second box appearing from nowhere, because that is exactly
   what it was.

   The lesson generalises: a control re-dressed for a new context inherits
   its old context's FOCUS and hover states too, and those are the ones
   nobody looks at while styling. Reset the box outright - height, width,
   border, radius, shadow - rather than only what shows at rest. */
/* THE SELECT NO LONGER DRAWS THE VALUE. It covers the whole pill,
   invisible, and exists to open the platform's picker; the text beside it
   is ours. That is what lets the same control read "Visit 3 · 14 Mar" on
   a laptop and "3" on a phone - a native select cannot be told to render
   a different string from the option it has selected.

   `opacity: 0`, not `visibility: hidden` and not `appearance: none` with
   transparent text: the first two stop it receiving taps, and the third
   still renders the value on some platforms. It keeps its full size so
   the tap target is the pill, not a sliver of it. */
.vwfilter select {
  position: absolute; inset: 0; width: 100%; height: 100%;
  min-height: 0; margin: 0; padding: 0;
  appearance: none; -webkit-appearance: none;
  border: 0; border-radius: inherit; box-shadow: none;
  background: transparent; color: transparent;
  font: inherit; font-size: var(--fs-note);
  opacity: 0; cursor: pointer;
}
/* A pill that only ever states a value. The visible revision shows on
   every sheet, but a drawing with one revision has nothing to switch to,
   so the chevron goes and the pill stops reading as a control. */
.vwfilter-static { cursor: default; }
.vwfilter-static:active { background: rgba(255,255,255,.16); }
.vwfilter-static .vwfilter-caret { display: none; }
.vwfilter-static select { cursor: default; }
.vwfilter-value {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: var(--fs-note); font-variant-numeric: tabular-nums;
}
/* Full text by default; the number alone once the bar is tight. The
   breakpoint is on the WINDOW, so rotating a phone changes it without
   anything having to listen. */
.vwfilter-value.short { display: none; }
@media (max-width: 560px) {
  .vwfilter-value.long { display: none; }
  .vwfilter-value.short { display: inline; min-width: 1.1em; text-align: center; }
  /* Collapsed, the pill is only as wide as its contents - the whole
     point of the exercise is to give the width back to the drawing. */
  .vwfilter { max-width: none; padding: 0 6px; gap: 4px; }
}
.vwfilter select:focus,
.vwfilter select:focus-visible {
  outline: none; border: 0; box-shadow: none;
}
/* The focus ring goes on the PILL, which is the thing a person sees as
   the control - not on the invisible box inside it. */
.vwfilter:focus-within { border-color: #fff; background: rgba(255,255,255,.28); }
/* The list is drawn by the platform and does not inherit the dark bar, so
   the options carry their own colours or they come out white on white. */
.vwfilter select option { color: var(--ink); background: var(--bg); }
/* THE GROUP LABEL IS SUBJECT TO THE SAME RULE. A revision marker is an
   <optgroup>, and it was the one element the line above did not name - so
   it rendered as a blank white row in the dark bar, the exact failure that
   note predicts. Dimmer than the visits under it: it is a heading, not a
   choice. */
.vwfilter select optgroup { color: var(--ink-2); background: var(--bg); }

/* Available to a screen reader, invisible on screen. The filter's real
   label ("Show comments for") does not fit in the bar, and a dropdown
   with no accessible name is a control that announces nothing. */
.sr-only {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* The row that used to just be .vwstage. Splitting it out is what lets the
   thumbnail rail take real width from the layout - a normal flex sibling,
   sized by the column it already knows how to fill - rather than an
   overlay that would need position:absolute and a reason to trust it does
   not fight the bars above and below it for space. */
.vwbody {
  display: flex; flex: 1; min-height: 0;
}

/* touch-action: none is what stops the browser hijacking a drag as a page
   scroll or a pinch as a page zoom; without it the sheet fights the page
   on every gesture. overflow hidden keeps the CSS-scaled canvas clipped. */
.vwstage {
  position: relative; flex: 1; min-width: 0; min-height: 0; overflow: hidden;
  background: #6b7280; touch-action: none; cursor: grab;
  -webkit-user-select: none; user-select: none;
}
.vwstage:active { cursor: grabbing; }

/* THE POOLED PAGE CANVASES live in here, one per visible page, created
   and destroyed by renderVisiblePages. A plain positioning host: each
   canvas is absolutely placed by its own transform, so this box only has
   to be the origin they are placed against and must not clip - the
   stage above already does that. */
.vwpages { position: absolute; inset: 0; }
.vwpages .vwcanvas { position: absolute; top: 0; left: 0; }

/* A sheet shown at an older revision than current, chosen for this
   sitting: framed so it cannot be mistaken for the current drawing. One
   frame per such page, placed in screen pixels by paintRevisionFrames so
   the border keeps its width at any zoom. Never takes a pointer event. */
.vwframes { position: absolute; inset: 0; pointer-events: none; }
.vwrevframe {
  position: absolute; top: 0; left: 0; box-sizing: border-box;
  border: 4px solid var(--rev-old);
  box-shadow: 0 0 0 1px rgba(0,0,0,.25);
}
.vwrevframe-tag {
  position: absolute; top: 0; left: 0; max-width: 100%;
  padding: 3px 8px; border-bottom-right-radius: var(--r-sm);
  background: var(--rev-old); color: var(--rev-old-ink);
  font-size: var(--fs-cap); font-weight: 600; letter-spacing: .02em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* THE MARKUP LAYER, TURNED OFF. `visibility` rather than `display`: the
   pins keep their boxes, so positionPins goes on working and turning
   them back on is a repaint rather than a re-layout of every bubble.
   pointer-events goes too, or an invisible pin still swallows a tap. */
.vwoverlay.marksoff, .vwpins.marksoff { visibility: hidden; pointer-events: none; }
/* The sheet's own text layer (links, find hits) is not markup: hiding the
   markup leaves it showing. */
.vwoverlay.marksoff .vwtextlayer { visibility: visible; }
.vwtextlayer rect { pointer-events: none; }
/* A sheet reference: a quiet outline in the brand blue, tinted, so the
   drawing reads through it and the link still shows. */
.vwtextlayer .vwref { fill: var(--sheet-link-fill); stroke: var(--sheet-link); stroke-opacity: .7; }
/* Find hits: highlighter yellow, the current one outlined in orange. The
   sheet is paper in either scheme, so the tokens do not change in dark. */
.vwtextlayer .vwhit { fill: var(--find-hit-fill); stroke: var(--find-hit); }
.vwtextlayer .vwhit.current { fill: var(--find-current-fill); stroke: var(--find-current); }

/* THE FIND BAR: find, and Back from a followed reference. */
.vwfindbar {
  display: flex; align-items: center; gap: 8px; flex: none;
  padding: 6px 10px; background: var(--bg); color: var(--ink);
  border-bottom: 1px solid var(--line);
}
.vwrefback { flex: none; }
.vwfind { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; }
.vwfind input[type="text"] { flex: 1; min-width: 0; min-height: 36px; padding: 4px 10px; font-size: var(--fs-body); }
.vwfind-count { flex: none; font-size: var(--fs-note); color: var(--ink-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
.vwfind .vwbtn:disabled { opacity: .4; cursor: default; }

/* THE RAIL. Collapsed is width:0 with its content clipped, not [hidden] -
   [hidden] cannot be transitioned, since it works by switching display,
   and a width that jumps from 0 to full reads as a glitch rather than a
   drawer opening. flex:none so vw-stage - flex:1 above - takes whatever
   width the rail gives up. The open width lives in a custom property, not
   a fixed value, so the resizer (below) can change it without fighting
   this rule for control of the same declaration. position:relative because
   the resizer nub is placed against this box's own right edge. */
.vwthumbs {
  position: relative;
  flex: none; width: 0; overflow: hidden;
  background: var(--bg); border-right: 1px solid var(--line);
  transition: width .18s ease;
}
.vwthumbs.open { width: var(--thumbs-w, 92px); }
/* No transition while the nub is actively being dragged - animating a
   value that is also being driven by the pointer makes the rail lag
   behind the finger instead of tracking it. */
.vwthumbs.resizing { transition: none; }
.vwthumbs-list {
  height: 100%; overflow-y: auto; overflow-x: hidden;
  display: flex; flex-direction: column; gap: 8px;
  padding: 8px; box-sizing: border-box;
  /* The list itself must not be reachable while collapsed - a hidden
     drawer that still lets a swipe scroll it, or a screen reader land in
     it, is not actually closed. */
}
.vwthumbs:not(.open) .vwthumbs-list { visibility: hidden; }

/* THE MARKUPS LIST. On a desk a column beside the stage, taking real
   width so the drawing reflows rather than sliding under it; on a phone
   the whole screen, over the bars, since a table beside a drawing on a
   390 px screen is neither. */
#p-viewer { --vwlist-w: min(580px, 46vw); }
.vwlist {
  flex: none; width: var(--vwlist-w); min-height: 0;
  display: flex; flex-direction: column;
  background: var(--bg); color: var(--ink);
  border-left: 1px solid var(--line);
}
@media (max-width: 760px) {
  .vwlist {
    position: fixed; inset: 0; z-index: 20; width: auto;
    border-left: 0;
    padding-top: env(safe-area-inset-top);
    padding-bottom: var(--safe-b, env(safe-area-inset-bottom));
  }
}
/* The scale pill sits over the stage's right edge, which the list moves. */
@media (min-width: 761px) {
  .panel.viewer.vwlist-open .vwscalepill { right: calc(10px + var(--vwlist-w) + var(--scale-lift-x, 0px)); }
}
.vwlist-head {
  display: flex; align-items: center; gap: 8px; flex: none;
  padding: 8px 10px; border-bottom: 1px solid var(--line);
}
.vwlist-head strong { font-family: var(--font-head); font-weight: 600; font-size: var(--fs-title); }
.vwlist-count { flex: 1; min-width: 0; font-size: var(--fs-note); color: var(--ink-2); }
.vwlist-filters {
  display: grid; grid-template-columns: 1fr 1fr; gap: 6px 8px; flex: none;
  padding: 8px 10px; border-bottom: 1px solid var(--line-2);
}
.vwlist-filters select { min-height: 36px; padding-top: 4px; padding-bottom: 4px; font-size: var(--fs-body); }
.vwlist-filters label.f > span { margin-bottom: 2px; }
.vwlist-totals {
  display: flex; flex-wrap: wrap; gap: 6px; flex: none;
  padding: 8px 10px; border-bottom: 1px solid var(--line-2); background: var(--bg-2);
}
.vwlist-total {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 9px; border-radius: 999px; background: var(--bg);
  border: 1px solid var(--line); font-size: var(--fs-note);
}
.vwlist-total b { font-variant-numeric: tabular-nums; }
.vwlist-total .vwlist-per { color: var(--ink-2); }
/* The table scrolls inside its own box, both ways: seven columns are wider
   than a phone, and the page must never scroll sideways. */
.vwlist-scroll { flex: 1; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; }
.vwlist-table { width: 100%; border-collapse: collapse; font-size: var(--fs-note); }
.vwlist-table th {
  position: sticky; top: 0; z-index: 1;
  background: var(--bg-2); border-bottom: 1px solid var(--line);
  text-align: left; padding: 0; white-space: nowrap;
}
.vwlist-table th button {
  display: flex; align-items: center; gap: 4px; width: 100%;
  min-height: 34px; padding: 0 6px; border: 0; background: none;
  font: inherit; font-weight: 600; color: var(--ink-2); cursor: pointer;
}
.vwlist-table th[aria-sort="ascending"] button,
.vwlist-table th[aria-sort="descending"] button { color: var(--accent); }
.vwlist-table th .ic { width: 12px; height: 12px; }
.vwlist-table th[aria-sort="ascending"] .ic { transform: rotate(180deg); }
.vwlist-table th:not([aria-sort]) .ic { visibility: hidden; }
.vwlist-table td {
  padding: 6px; border-bottom: 1px solid var(--line-2);
  vertical-align: top; white-space: nowrap;
}
.vwlist-table td.vwlist-text { white-space: normal; min-width: 120px; max-width: 240px; overflow-wrap: anywhere; }
.vwlist-table td.vwlist-qty { text-align: right; font-variant-numeric: tabular-nums; }
.vwlist-table tbody tr { cursor: pointer; }
.vwlist-table tbody tr:hover { background: var(--bg-2); }
.vwlist-table tbody tr.sel { background: var(--accent-lo); }
.vwlist-table tbody tr:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.vwlist-swatch {
  display: inline-block; width: 14px; height: 14px; border-radius: 50%;
  border: 1px solid var(--line); vertical-align: middle;
}
.vwlist-typesw { display: none; margin-right: 6px; }
/* On a phone the Color column folds into Type and Type stays put while
   the rest scrolls sideways, so a row is always named. */
@media (max-width: 760px) {
  .vwlist-typesw { display: inline-block; }
  .vwlist-table th:nth-child(5), .vwlist-table td:nth-child(5) { display: none; }
  .vwlist-table th:first-child, .vwlist-table td:first-child {
    position: sticky; left: 0; z-index: 1; background: var(--bg);
    border-right: 1px solid var(--line-2);
  }
  .vwlist-table th:first-child { z-index: 2; background: var(--bg-2); }
  .vwlist-table tbody tr.sel td:first-child { background: var(--accent-lo); }
}
.vwlist-empty { margin: 16px 10px; color: var(--ink-2); font-size: var(--fs-note); }

/* THE RESIZER NUB. Lives inside .vwthumbs, never inside .vwstage - the
   stage takes setPointerCapture on its own pointerdown, and portal-viewer
   already carries two other lessons (comment pins, the photo annotator's
   resize grip) about what happens to a control placed where that capture
   can retarget its events. A sibling of the stage is never a descendant
   of it, so that risk does not apply here regardless. */
/* 22px wide, not the 14px it started at: it is grabbed with a thumb on a
   moving boat, and the previous hit area was a target you had to aim at.
   The grip inside it is drawn larger for the same reason - a 3px hairline
   did not read as something you could take hold of. */
.vwthumbs-resizer {
  position: absolute; top: 0; right: 0; bottom: 0; width: 22px;
  cursor: col-resize; touch-action: none;
  display: flex; align-items: center; justify-content: center;
}
.vwthumbs-resizer::after {
  content: ''; width: 5px; height: 48px; border-radius: var(--r-xs);
  background: var(--line);
}
.vwthumbs-resizer:active::after,
.vwthumbs.resizing .vwthumbs-resizer::after { background: var(--accent); }
.vwthumbs:not(.open) .vwthumbs-resizer { visibility: hidden; pointer-events: none; }
.vwthumb {
  flex: none; width: 100%; padding: 0; border: 2px solid transparent;
  border-radius: var(--r-sm); background: none; cursor: pointer; overflow: hidden;
  display: flex; flex-direction: column; gap: 3px;
}
.vwthumb-pic {
  position: relative;
  width: 100%; background: #6b7280;
  border-radius: var(--r-xs); overflow: hidden;
  display: flex; align-items: center; justify-content: center;
}

/* THE COUNT LIVES ON THE THUMBNAIL. It used to float in the corner of the
   stage, which told you about the sheet already open - the one sheet you
   do not need told about. Here it says where the walk went before you
   open anything.

   Top right, small, and capped at two bubbles: the rail goes down to 64px
   and a third bubble would either shrink the numbers out of legibility or
   cover the drawing it is meant to describe. */
.vwthumb-badges {
  position: absolute; top: 3px; right: 3px; gap: 2px;
  filter: drop-shadow(0 1px 2px rgba(0,0,0,.5));
}
.vwthumb-badges .cmbadge {
  min-width: 15px; height: 15px; padding: 0 3px;
  font-size: var(--fs-tiny); border: 1px solid rgba(255,255,255,.9);
}
.vwthumb-badges .cmbadge.more {
  background: rgba(17,24,39,.75); color: #fff; border-color: rgba(255,255,255,.7);
}
.vwthumb-pic canvas { display: block; width: 100%; height: 100%; }
.vwthumb-label {
  font-size: var(--fs-micro); line-height: 1.25; color: var(--ink-2);
  text-align: center; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; padding: 0 2px;
}
.vwthumb[aria-current="true"] {
  border-color: var(--accent);
}
.vwthumb[aria-current="true"] .vwthumb-label { color: var(--ink); font-weight: 600; }
.vwthumb.rev-old .vwthumb-pic { outline: 3px solid var(--rev-old); outline-offset: -1px; }
.vwcanvas {
  position: absolute; top: 0; left: 0; transform-origin: 0 0;
  will-change: transform;
}
/* A BASE CANVAS IS PAPER-COLOURED BEFORE IT IS PAINTED. A canvas element
   starts transparent, so a page scrolled into on a continuous strip showed
   the stage behind it for a frame or two and then flicked to white as
   renderPage cleared it - a dark seam that arrived with the page boundary
   and read as a glitch rather than as loading. Painting the element itself
   means the frame in which the page is still blank is already the right
   colour, and the linework simply appears on it. */
.vwcanvas.base { background: #fff; }
/* The detail layer fades in over the blurry base rather than swapping,
   so a slow render never flashes. */
.vwcanvas.detail { opacity: 0; transition: opacity .12s linear; pointer-events: none; }
/* A dropped detail layer is out of the page entirely: on iOS an invisible
   layer left larger than the stage stops taps on the bars from becoming
   clicks. */
.vwcanvas.detail[data-live="0"] { visibility: hidden; }
/* Fills the stage at 1:1 device pixels and pans/zooms via its viewBox, NOT
   via a CSS transform. A transform scales the element's raster, which is
   why ink used to blur when zoomed in; re-aiming the viewBox makes the
   browser re-rasterise the vectors sharply at every zoom level.
   No `will-change: transform` here for the same reason - it would promote
   the layer and reintroduce exactly the caching that caused the blur. */
.vwoverlay {
  position: absolute; inset: 0; width: 100%; height: 100%;
  overflow: visible; pointer-events: none;
}
.vwoverlay text, .vwoverlay path { pointer-events: none; }
.vwoverlay text[data-id] { pointer-events: auto; }
/* THE INK OVERLAY IS NOT TEXT, even where it contains <text>. The same
   rule and the same reason as `.anoverlay` further down: the
   "Copy · Look Up · Translate" menu is the TEXT SELECTION callout, an SVG
   <text> node is selectable content like any other, and the line above
   deliberately gives the tappable ones `pointer-events: auto`. Writing
   across a text mark selected it, Safari raised its menu, and the callout
   cancelled the pointer sequence - taking the stroke with it. A rule on
   the panel does not reach into an SVG subtree reliably, so every element
   in here is made unselectable explicitly. */
.vwoverlay,
.vwoverlay * {
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none;
}
/* Pins live in an UNSCALED layer so they keep a constant screen size and
   stay tappable at fit zoom. The layer itself must not eat pointer events
   meant for the sheet - only the pins themselves do. */
.vwpins { position: absolute; inset: 0; pointer-events: none; }
.vwpin {
  position: absolute; top: 0; left: 0; pointer-events: auto;
  min-width: 28px; height: 28px; padding: 0 6px;
  border: 2px solid #fff; border-radius: 14px 14px 14px 2px;
  /* COLOURED BY VISIT - set inline from C.visitColour, which is also what
     colours the sheet-index counters and the thumbnail badges. The
     fallback is the red visit 1 has always been, so a record that somehow
     has no visit still draws. */
  background: var(--visit, var(--bad)); color: #fff; cursor: pointer;
  font: inherit; font-size: var(--fs-note); font-weight: 700; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 1px 4px rgba(0,0,0,.45);
}
/* PICKED UP BY A LONG PRESS, and the lift is the whole feedback. The
   gesture is deliberately one nobody performs by accident (see
   C.pinDragDecision), so it has to announce that it worked before the
   finger moves - otherwise a hold that armed and a hold that did not look
   identical, and somebody drags nothing.

   NOTHING HERE MAY AFFECT POSITION - no transform, no scale, no
   translate, no margin. positionPins writes this bubble's transform on
   every frame, and the individual transform properties (scale, rotate,
   translate) are applied to the RESULT of the `transform` property, so a
   `scale: 1.25` here multiplied that translate: the bubble tracked the
   pointer at 1.25x its distance from the stage origin and only landed
   correctly because releasing removed the class.

   A ring, a deeper shadow and a touch of brightness say "picked up"
   without entering the transform chain at all. `outline` is drawn outside
   the box and takes no layout, and `filter` moves nothing. */
.vwpin.lifted {
  box-shadow: 0 6px 16px rgba(0,0,0,.6);
  outline: 3px solid #fff;
  outline-offset: 1px;
  filter: brightness(1.15);
  cursor: grabbing;
  z-index: 2;
}
/* "THIS DOES NOT MOVE": a comment pinned to a snapshot, dragged after a
   hold, shakes with its window. The `translate` PROPERTY only - it adds to
   the bubble's inline `transform` position, where a `transform`, `rotate`
   or `scale` keyframe would replace or swing it. On the SVG frame the
   percentages are of the frame's own box, so the shake reads the same at
   any zoom. */
@keyframes wiggle {
  0%, 100% { translate: 0 0; }
  20% { translate: -5px 0; }
  40% { translate: 5px 0; }
  60% { translate: -3px 0; }
  80% { translate: 3px 0; }
}
@keyframes wiggleframe {
  0%, 100% { translate: 0 0; }
  20% { translate: -2% 0; }
  40% { translate: 2% 0; }
  60% { translate: -1.2% 0; }
  80% { translate: 1.2% 0; }
}
.vwpin.wiggle { animation: wiggle .42s ease-in-out; }
.vwoverlay rect.wiggle {
  transform-box: fill-box;
  animation: wiggleframe .42s ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
  .vwpin.wiggle, .vwoverlay rect.wiggle { animation: none; }
}
/* A COMMENT CARRYING A PHOTO says so with a pip, not with a colour. It
   used to turn accent-blue, which now means "visit 2" - two different
   facts cannot share one channel. */
.vwpin.haspic::after {
  content: ''; position: absolute; top: -3px; right: -3px;
  width: 9px; height: 9px; border-radius: 50%;
  background: #fff; box-shadow: 0 0 0 1.5px rgba(0,0,0,.4);
}
/* INFORMATIONAL: NO VISIT COLOUR. The colour on a bubble means "this is
   a finding from walk N", and an informational comment is not a finding -
   it is field data that never reaches the punch list. So it reads as
   neutral ink rather than as a walk, and a plan covered in bubbles still
   shows at a glance which of them are work.

   Declared AFTER the base rule and without --visit, so the inline
   custom property the renderer sets for every other bubble has nothing
   to colour here. The dashed draft style is not reused: a draft is a
   comment that does not exist yet, and this one is saved. */
.vwpin.info {
  background: var(--ink-2);
  border-color: rgba(255,255,255,.85);
}
.vwpin:active { filter: brightness(1.15); }

/* The comment being written, before it is a record. Hollow and dashed so
   it cannot be mistaken for a saved finding, and pulsing so it is findable
   on a dense sheet in daylight. It is disabled, so it takes no taps. */
/* AND IT TAKES NO POINTER EVENTS. `.vwpin` sets `pointer-events: auto`
   and `::before` gives it a 9px expanded hit area, so a display-only
   bubble still swallowed the press - and a DISABLED button swallows it
   outright, dispatching to neither itself nor the stage behind it. That
   is what stopped the tap placing a duplicated comment: the ghost sits
   directly under the pointer by construction, so the stage never saw the
   pointerdown and the release had no gesture to finish. Inherited, so it
   covers the pseudo-element too. */
.vwpin.draft {
  background: transparent; color: #fff;
  border-style: dashed; box-shadow: 0 0 0 2px rgba(0,0,0,.35);
  animation: vwdraft 1.4s ease-in-out infinite;
  pointer-events: none;
}
@keyframes vwdraft {
  0%, 100% { opacity: 1; }
  50% { opacity: .45; }
}
/* Anyone who has asked not to be moved at gets a static bubble instead. */
@media (prefers-reduced-motion: reduce) {
  .vwpin.draft { animation: none; }
}

/* THE FLOATING PAGE BUTTONS ARE GONE. They existed because there was no
   bottom bar to put them in - two round bubbles over the sheet, on the
   argument that a permanent strip of chrome cost more than they did.
   There is a bar now and its middle group is prev / counter / next, so
   these were both a second set of controls for one action and the pair
   sitting over the drawing.

   One thing from them is worth keeping in mind, and the bar's buttons do
   it too: they DIMMED at the ends of the set rather than disappearing,
   because a control that vanishes reads as a fault and its absence says
   nothing about why. */

/* =====================================================================
   THE BOTTOM BAR

   Page size and zoom left, the page counter middle, view controls right.

   IT IS ALWAYS VISIBLE, and it takes A REAL ROW of the panel's column.

   Both of those replaced their own opposite, and the order matters. An
   earlier version faded the bar out after a few idle seconds; on glass
   that was worse than the permanent strip it was avoiding, because the
   controls disappeared exactly while somebody was reading the drawing
   and deciding what to do next - so every third interaction began with a
   tap that did nothing but summon the bar back.

   The bar also FLOATED, over the bottom of the sheet, and that was the
   right call while it came and went: as a flow row it changes the
   stage's height, a ResizeObserver re-derives the fit scale and the zoom
   limits from that height, and the sheet would have jerked on every fade
   in and out. Nothing fades any more, so the height changes exactly once
   - when the viewer opens - and the drawing gets those pixels back
   instead of being covered by them. A permanent bar that obscures the
   bottom inch of a plan is a worse deal than a permanent bar that does
   not.

   NOT INSIDE .vwstage. The stage clips its overflow and captures its own
   pointers, and this file already carries the note on .vwthumbs-resizer
   about what happens to a control placed inside that capture. It is the
   last row of the panel's flex column, after .vwbody.

   THE HEIGHT IS A CONSTANT, declared once, like --tabh. If this bar ever
   appears to change height, instrument the device before editing the
   rule - see references/ui-and-layout.md, where the tab bar cost three
   fixes aimed at the wrong property.

   The home-indicator inset is taken once, here, through --safe-b. Do not
   re-add a JavaScript latch on env(safe-area-inset-bottom); that was
   tried, the device disproved the theory it defended, and a smoke
   assertion checks it has not come back. */
/* --vwbarh is ONE ROW. --vwbarrows is how many rows the bar is actually
   using, set on the root element by fitBottomBar after it has MEASURED
   whether the controls fit - see the note there. --vwbartotal is the
   height everything else has to clear, derived from the two so there is
   one place the arithmetic lives: the dock's height, the snapshot pill,
   the small prompts and the scale dialog all read it, and four offsets
   agreeing by hand is four chances to disagree. */
:root {
  --vwbarh: 52px;
  --vwbarrows: 1;
  --vwbartotal: calc(var(--vwbarh) * var(--vwbarrows));
}

.vwbottom {
  /* A flow row: flex:none so .vwbody above keeps taking the slack, and
     z-index only so the comment sheet (5) and the snapshot pill (6) still
     come over it when they are up. */
  flex: none; position: relative; z-index: 4;
  display: flex; align-items: center; justify-content: space-between;
  gap: 6px; height: var(--vwbartotal); padding: 0 8px;
  /* The home-indicator inset, taken once. content-box so --vwbarh stays
     the height of the CONTROLS and the inset is added below them rather
     than eaten out of them. env() nested inside var() inside max() has
     evaluated to zero on iOS, so the inset is never spelt var(--safe-b)
     inside a max() here.

     A FLOOR UNDER THE INSET: 21 px, the inset a phone reports in
     landscape, so the corner controls clear the screen's rounded corners
     even when the portrait inset arrives late or reads as zero. env() is
     written directly in the max(), not through var(--safe-b). Everything
     that clears the bar reads its measured height (--vwbar-clear, set by
     fitBottomBar) rather than recomputing this. */
  padding-bottom: max(21px, env(safe-area-inset-bottom, 0px));
  box-sizing: content-box;
  background: var(--navy); color: #fff;
  border-top: 1px solid rgba(255,255,255,.14);
}

.vwbgroup { display: flex; align-items: center; gap: 6px; min-width: 0; }
.vwbgroup.middle { flex: 0 1 auto; }
/* The two outer groups collapse before the counter does: on a phone the
   counter is the one thing that must stay readable. */
.vwbgroup.left, .vwbgroup.right { flex: 0 1 auto; overflow: hidden; }

/* The bar's own segmented group. Same idea as `.seg` in a card, sized
   and coloured for a dark bar sitting over a drawing. */
.vwseg {
  display: flex; align-items: center; flex: none;
  border: 1px solid rgba(255,255,255,.28); border-radius: var(--r-sm);
  overflow: hidden; background: rgba(255,255,255,.10);
}
.vwseg .vwbbtn { border-radius: 0; background: transparent; }
.vwseg .vwbbtn + .vwbbtn { border-left: 1px solid rgba(255,255,255,.18); }

.vwbbtn {
  display: inline-flex; align-items: center; justify-content: center;
  flex: none; min-width: 38px; height: 38px; padding: 0 8px;
  border: 0; border-radius: var(--r-sm);
  background: rgba(255,255,255,.10); color: #fff;
  font: inherit; font-size: var(--fs-note); font-weight: 600; cursor: pointer;
  font-variant-numeric: tabular-nums;
}
.vwbbtn .ic { width: 20px; height: 20px; }
.vwbbtn:active { background: rgba(255,255,255,.26); }
.vwbbtn[aria-pressed="true"] { background: var(--accent-fill); color: var(--on-accent); }
.vwbbtn:disabled { opacity: .3; cursor: default; }

/* =====================================================================
   THE MARKUP BUTTON, WIGGLED

   Armed when a tool is refused because the markup layer is hidden. The
   reason is a control on screen, so the answer points at it rather than
   putting a sentence in the middle of the drawing - on a dock, in
   weather, "that button" beats something that has to be read.

   TRANSFORM ONLY, no layout properties: this runs on the bottom bar,
   which measures itself to decide whether it needs two rows (see
   fitBottomBar), and animating a width or a margin here would have it
   re-measuring mid-wiggle and flipping rows underneath the drawing.

   The button also carries `background` from its pressed state, so the
   wiggle is deliberately not a colour change - it would fight the
   aria-pressed rule above and read as the toggle having flipped. */
@keyframes vw-wiggle {
  0%, 100% { transform: translateX(0) rotate(0); }
  15% { transform: translateX(-3px) rotate(-7deg); }
  30% { transform: translateX(3px) rotate(7deg); }
  45% { transform: translateX(-3px) rotate(-5deg); }
  60% { transform: translateX(2px) rotate(4deg); }
  80% { transform: translateX(-1px) rotate(-2deg); }
}
.vwbbtn.wiggle { animation: vw-wiggle .45s ease-in-out; }
/* Somebody who has asked for less motion still needs the answer, so the
   button flashes its outline instead of moving. Not nothing: the refusal
   would otherwise be silent for exactly the people most likely to have
   turned animation off. */
@media (prefers-reduced-motion: reduce) {
  .vwbbtn.wiggle { animation: none; outline: 2px solid var(--accent); }
}
/* The counter. Wide enough for "E1.1 · 12 of 38" without wrapping, and
   it truncates rather than pushing the groups either side off the bar. */
.vwbbtn.wide {
  min-width: 0; max-width: 44vw; padding: 0 12px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  display: block; line-height: 38px;
}
/* The percentage between the two zoom buttons, inside the group. */
.vwbottom .vwzoom {
  min-width: 46px; text-align: center; font-size: var(--fs-small); opacity: .9;
  font-family: var(--mono);
}

/* =====================================================================
   THE BAR ON A PHONE

   It did not fit. Three groups, ten controls and a scale readout wide
   enough to say `1/4" = 1'-0"` came to well over 390px, so the middle
   group - the one that matters most, because it is how you know where you
   are and how you get somewhere else - was squeezed to nothing.

   WHAT GOES, AND WHY EACH ONE IS THE RIGHT THING TO DROP:

     - The ZOOM pair. Pinch does that job on a phone, better, and the
       percentage is still readable in the About sheet's diagnostics if
       anybody ever needs the number.
     - ACTUAL SIZE. "The sheet at the size it would be in your hand" is a
       desk question asked of a check print; on a phone the hand is
       holding the phone.

   WHAT STAYS: the fit toggle, prev / counter / next, the scale readout,
   and the markup switch. Nothing that answers "where am I", "what is
   this sheet's scale" or "let me see the drawing clean.

   Sized down rather than only thinned out: 32px targets and 4px gaps,
   which is tight for a primary control and right for a bar of them where
   the thing being aimed at is the sheet behind it. */
@media (max-width: 560px) {
  /* 16 px in from the sides, clear of the rounded corners. */
  .vwbottom { gap: 4px; padding-left: 16px; padding-right: 16px; }
  .vwbgroup { gap: 4px; }
  .vwbbtn { min-width: 32px; padding: 0 4px; }
  .vwbbtn .ic { width: 18px; height: 18px; }
  /* The counter keeps its share and truncates inside it. */
  .vwbbtn.wide { max-width: 40vw; padding: 0 8px; }
}
@media (max-width: 430px) {
  /* Zoom and actual size go, in that order - see above. */
  .vwbgroup.left .vwseg:nth-child(3) { display: none; }
  .vwbgroup.left .vwseg:nth-child(2) { display: none; }
  .vwbbtn.wide { max-width: 46vw; }
  /* A shorter row, because two of them is a lot of chrome to spend on a
     phone in portrait - which is exactly where two of them are needed. */
  :root { --vwbarh: 46px; }
}

/* =====================================================================
   TWO ROWS, WHEN ONE DOES NOT FIT

   MEASURED, NOT GUESSED. Breakpoints were the first attempt and they
   cannot answer this question: whether the bar fits depends on the length
   of the sheet number, the length of the scale label - `1 1/2" = 1'-0"`
   is half again as wide as `1" = 40'` - the platform's font metrics and
   the user's text-size setting, none of which a `max-width` in the
   stylesheet knows about. It fitted on the phone it was tried on and did
   not on the next one.

   So fitBottomBar lays the bar out UNCAPPED for one frame, asks whether
   it overflows, and sets --vwbarrows from the answer. See the note there
   for why a forced layout is the honest cost.

   THE CORNERS GO UP, THE PAGE NAVIGATION STAYS DOWN. The counter and its
   arrows are the most-used controls on the bar and the ones a thumb
   reaches for, so they keep the bottom row; the fit toggle, the scale and
   the markup switch move above them. `order` rather than a different DOM,
   so the markup reads in one order and only the layout changes.
   ===================================================================== */
.vwbottom.tworow {
  flex-wrap: wrap;
  align-content: stretch;
}
.vwbottom.tworow .vwbgroup.left { order: 1; }
.vwbottom.tworow .vwbgroup.right { order: 2; }
.vwbottom.tworow .vwbgroup.middle {
  order: 3; flex: 0 0 100%; justify-content: center;
}
/* With two rows there is room again, so the desk controls the phone
   media query hid come back rather than staying hidden for a width that
   is no longer the constraint. */
.vwbottom.tworow .vwbgroup.left .vwseg { display: flex; }
/* Both text buttons get more room on their own row. */
.vwbottom.tworow .vwbbtn.wide { max-width: 62vw; }

/* THE MEASURING STATE, on for one frame at a time. Caps off, nothing
   allowed to shrink, nothing clipped - so the bar's scrollWidth is what
   the controls actually WANT rather than what they were squeezed into.
   Without lifting the caps the bar never overflows and the question can
   never be answered: the counter just gets narrower until it says
   nothing. */
.vwbottom.measuring { flex-wrap: nowrap; }
.vwbottom.measuring .vwbgroup { flex: 0 0 auto; overflow: visible; }
.vwbottom.measuring .vwbbtn.wide {
  max-width: none; overflow: visible; text-overflow: clip;
}

/* =====================================================================
   THE QUICK REFERENCE WINDOW

   A floating, movable, resizable window over the sheet. ABSOLUTE, not
   fixed: it is positioned against `.panel.viewer.active`, which is itself
   `position: fixed; inset: 0`, so absolute already means "against the
   viewport" here without this box having to be a second fixed layer -
   and being absolute is what lets JS clamp it to the `.vwbody` box, so it
   can never be dragged over the app bar or the bottom bar.

   z-index 7 puts it over the bottom bar (4), the comment sheet (5) and
   the snapshot pill (6). Deliberately over the comment sheet: reading a
   schedule while writing the finding it is about is one of the two things
   this window is for, and a utility window that disappears under the
   panel you opened it to help with is a window you close and stop using.

   GEOMETRY COMES IN AS FOUR CUSTOM PROPERTIES, set by JS from
   C.quickRefFrame and nothing else. No inline `style="..."` in the
   markup - the deployed CSP is `style-src 'self'` with no
   'unsafe-inline', so a style ATTRIBUTE is discarded by the browser; a
   property set from script is not, which is the distinction that matters
   here. See the note in SKILL.md.

   `overflow: hidden` is safe on this box specifically: nothing inside it
   is `position: fixed`. The rule it would otherwise break - an overflow
   value taking away the viewport as a fixed child's containing block -
   is the one at the top of this file. Do not put a fixed element in
   here.
   ===================================================================== */
/* THE DOCK, bottom left of the drawing. Its height is measured up from
   the bottom bar - `--vwbartotal` plus the home-indicator inset - rather
   than from the viewport, so it rides with the bar when the bar goes to
   two rows on a phone instead of being buried by it.

   `--qr-dock-x` clears the thumbnail rail, and is set from
   applyThumbsWidth: opening the rail slides the dock out from under it.
   The dock is the window's minimised state, so it is on screen only
   while the window is not.

   `--qr-dock-y` IS THE SAME IDEA ON THE OTHER AXIS, and it is why the
   bottom is a `max()`. The comment sheet slides up from the bottom to as
   much as 82% of the panel, and the dock sits at z-index 7 against the
   sheet's 5 - so pinned to the bar alone it hovered over the middle of an
   open comment, attached to nothing. It now clears whichever is taller,
   the bar or the open sheet. Measured rather than declared: the sheet's
   height is `max-height`, so it depends on the content and on the
   keyboard inset, and a constant here would leave a gap or an overlap on
   everything except one comment. Set from syncQrDockY. */
.vwqr-dock {
  position: absolute; z-index: 7;
  /* --qr-lift-x / -y move it clear of a left or bottom tool dock that
     reaches this corner; set from placeQrDock (C.qrDockLift). */
  left: calc(10px + var(--qr-dock-x, 0px) + var(--qr-lift-x, 0px));
  /* The inner max is gone for the reason .vwbottom's was: a no-op that
     nests env() inside var() inside max(), which WebKit has evaluated to
     zero. The OUTER max stays - it is a real choice between the bar and
     the comment sheet, and neither of its terms is an env(). */
  bottom: calc(max(var(--vwbar-clear, calc(var(--vwbartotal) + var(--safe-b))),
                   var(--qr-dock-y, 0px)) + 10px + var(--qr-lift-y, 0px));
  display: inline-flex; align-items: center; gap: 6px;
  height: 40px; padding: 0 12px; border: 0; border-radius: 20px;
  background: var(--navy); color: #fff; cursor: pointer;
  box-shadow: 0 6px 18px rgba(0,0,0,.34);
}
.vwqr-dock .ic { width: 20px; height: 20px; }

/* THE SHEET'S SCALE, the quick-reference button's mirror in the
   bottom-right corner, and only shown once a scale is set. The same
   clearance of the bar and of an open comment sheet; --scale-lift-x / -y
   move it clear of a right or bottom tool dock (placeQrDock). */
.vwscalepill {
  position: absolute; z-index: 7;
  right: calc(10px + var(--scale-lift-x, 0px));
  bottom: calc(max(var(--vwbar-clear, calc(var(--vwbartotal) + var(--safe-b))),
                   var(--qr-dock-y, 0px)) + 10px + var(--scale-lift-y, 0px));
  max-width: calc(60vw - 20px);
  height: 40px; padding: 0 14px; border: 0; border-radius: 20px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  background: var(--navy); color: #fff; cursor: pointer;
  font: inherit; font-size: var(--fs-note); font-weight: 600;
  box-shadow: 0 6px 18px rgba(0,0,0,.34);
}
.vwscalepill:active { background: var(--accent); }
.vwqr-dock:active { background: var(--accent); }
/* How many references are on the shelf, so the dock says whether there is
   anything in there before it is opened. Hidden at zero rather than
   showing a nought - "nothing here" is what the empty window says, and it
   says it in a sentence. */
.vwqr-dockn {
  font-size: var(--fs-small); font-weight: 600; font-family: var(--mono);
  min-width: 9px; text-align: center;
}

.vwqr {
  position: absolute; z-index: 7;
  left: var(--qr-x, 0px); top: var(--qr-y, 0px);
  width: var(--qr-w, 320px); height: var(--qr-h, 260px);
  display: flex; flex-direction: column;
  border-radius: var(--r-md); overflow: hidden;
  background: var(--bg); color: var(--ink);
  border: 1.5px solid var(--line);
  box-shadow: 0 12px 32px rgba(0,0,0,.34);
}

/* ---- OPENING OUT OF THE DOCK, AND FOLDING BACK INTO IT ----

   The window does not appear and disappear; it grows out of the dock
   button and collapses back into it, so the gesture says where the shelf
   went and where it will come back from. That is the whole reason the
   dock is a floating button at the bottom left rather than another icon
   in the top bar.

   A FLIP: the window is laid out at its real place and size FIRST, then
   a transform maps that rectangle onto the dock's, and the transition
   runs the transform back to identity. Animating `left`/`top`/`width`/
   `height` instead would relayout the picture, the zoom row and every
   thumbnail on each frame, which on a phone over a 36x24 is exactly the
   jank the sheet viewer's render tiers exist to avoid.

   The transform is only present DURING the animation - `transform` takes
   the viewport away as a containing block for any fixed descendant, and
   although nothing inside this window is fixed, leaving one on
   permanently is how that becomes true by accident later. */
.vwqr.animating {
  transition: transform 200ms cubic-bezier(.2, .7, .3, 1), opacity 160ms ease;
  will-change: transform;
}
.vwqr.collapsed {
  transform: translate(var(--qr-tx, 0px), var(--qr-ty, 0px))
             scale(var(--qr-sx, .1), var(--qr-sy, .1));
  opacity: 0;
  /* A COLLAPSED WINDOW TAKES NO TAPS. It is invisible but still in the
     layer above the sheet until the transition ends and it is hidden -
     and under `prefers-reduced-motion` there is no transition, so it sits
     there transparent for the length of the fallback timer instead. A
     transparent window swallowing the tap meant to reach the drawing is
     the sort of fault nobody can describe. */
  pointer-events: none;
}

/* SOMEBODY WHO HAS ASKED FOR LESS MOTION GETS NONE. The window still
   opens and still minimises; it simply arrives. */
@media (prefers-reduced-motion: reduce) {
  .vwqr.animating { transition: none; }
}

/* THE TITLE BAR IS THE DRAG HANDLE, which is where the handle is on every
   platform anybody in the office uses, so it needs no affordance beyond
   looking like a title bar. `touch-action: none` is what stops the
   browser reading the drag as a page scroll. */
.vwqr-bar {
  flex: none; display: flex; align-items: center; gap: 6px;
  padding: 6px 8px; background: var(--navy); color: #fff;
  cursor: move; touch-action: none;
  -webkit-user-select: none; user-select: none;
}
.vwqr-bar .ic { width: 18px; height: 18px; flex: none; }
.vwqr-title {
  flex: 1; min-width: 0; font-size: var(--fs-note); font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.vwqr-btn {
  flex: none; width: 30px; height: 30px; border: 0; border-radius: var(--r-sm);
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,.16); color: #fff; cursor: pointer;
}
.vwqr-btn .ic { width: 17px; height: 17px; }
.vwqr-btn:active { background: rgba(255,255,255,.3); }
.vwqr-btn[aria-pressed="true"] { background: var(--accent); }
.vwqr-btn:disabled { opacity: .4; cursor: default; }
/* The add control is a <label>, which cannot be `disabled` - so the
   busy state is aria-disabled, and this is what makes it mean something.
   `pointer-events: none` is the part that matters: without it a second
   tap while a photo is still encoding opens the picker again. */
.vwqr-add[aria-disabled="true"] { opacity: .4; pointer-events: none; }

/* THE PICTURE IS CENTRED WITH `margin: auto`, NOT `justify-content`.
   A flex container that centres with justify-content and then scrolls
   clips the leading edge of an oversized child and gives you no way to
   scroll back to it - so at full size the left-hand column of a panel
   schedule was simply unreachable. Auto margins centre when it fits and
   collapse to zero when it does not. */
.vwqr-body {
  flex: 1; min-height: 0; position: relative;
  overflow: auto; -webkit-overflow-scrolling: touch;
  display: flex; background: var(--bg-2);
}
/* THE PICTURE'S BOX IS SIZED IN PIXELS BY JS, from C.quickRefPicSize -
   NOT by object-fit. The ink overlay sits at `inset: 0` on this box, so
   the box has to BE the picture: an image letterboxed inside a larger box
   by `object-fit: contain` leaves the overlay covering the letterbox as
   well, and every mark on the photo is then stretched off the thing it
   points at. Full size is the same rule with the scale set to 1, which is
   why there is no `.actual` variant here - only the two numbers change. */
.vwqr-pic {
  /* `relative` is required, not cosmetic: the ink overlay is absolutely
     positioned inside this and must land on the image, not the window. */
  position: relative; margin: auto; flex: none;
  width: var(--qr-pw, auto); height: var(--qr-ph, auto);
}
.vwqr-pic img { display: block; width: 100%; height: 100%; }
.vwqr-pic .photoink {
  position: absolute; inset: 0; width: 100%; height: 100%;
  pointer-events: none;
}

.vwqr-empty {
  margin: auto; padding: 14px; max-width: 260px;
  font-size: var(--fs-note); line-height: 1.45; color: var(--ink-2);
  text-align: center;
}

/* THE ZOOM ROW. Minus, the percentage, plus, fit - the same arrangement
   and the same meaning as the sheet viewer's bottom bar, where 100% is
   the picture at its own pixels. Its own row rather than four more icons
   in the title bar: at the 260px minimum width the bar has room for the
   name of the window and two controls. */
.vwqr-zoom {
  flex: none; display: flex; align-items: center; gap: 4px;
  padding: 4px 6px; background: var(--bg-2);
  border-top: 1px solid var(--line-2);
}
.vwqr-zbtn {
  flex: none; width: 30px; height: 28px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); color: var(--ink); cursor: pointer;
}
.vwqr-zbtn .ic { width: 16px; height: 16px; }
.vwqr-zbtn:active { background: var(--accent-lo); }
.vwqr-zbtn:disabled { opacity: .4; cursor: default; }
/* Fixed width and tabular, so stepping through 34% / 48% / 67% does not
   shuffle the buttons either side of it. */
.vwqr-pct {
  flex: none; min-width: 46px; text-align: center;
  font-size: var(--fs-small); font-family: var(--mono); color: var(--ink-2);
}

/* THE SHELF MENU. Always present, because it holds the + that adds to it
   - it is not a picker that can be empty, it is what the shelf is. */
.vwqr-strip {
  flex: none; display: flex; align-items: center; gap: 6px; padding: 6px;
  overflow-x: auto; background: var(--bg);
  border-top: 1px solid var(--line-2);
}
/* The wrapper exists so the x can be a real button: a button inside a
   button is invalid, and the thumbnail has to stay tappable. */
.vwqr-item { position: relative; flex: none; }
.vwqr-thumb {
  /* `relative` is required: the shelf-only dot is placed inside it. */
  position: relative;
  display: block; width: 46px; height: 46px; padding: 0; overflow: hidden;
  border: 2px solid transparent; border-radius: var(--r-sm);
  background: var(--bg-2); cursor: pointer;
}
.vwqr-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vwqr-thumb[aria-current="true"] { border-color: var(--accent); }

/* THE x, ON THE SELECTED THUMBNAIL ONLY. One per row would be four ways
   to throw something away in a 46px strip, on a boat. On the selected one
   it is unambiguous, and the picture it belongs to is the one filling the
   window above it.

   It hangs slightly outside its thumbnail, which is what stops it
   covering the picture it is asking about - so `.vwqr-item` must not
   clip, and only `.vwqr-thumb` does. */
.vwqr-x {
  position: absolute; top: -5px; right: -5px; z-index: 1;
  width: 20px; height: 20px; padding: 0; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1.5px solid var(--bg); background: var(--ink-2); color: #fff;
  cursor: pointer;
}
.vwqr-x .ic { width: 12px; height: 12px; }
/* Destructive reads as destructive before it is tapped: on a photo taken
   straight onto the shelf there is nowhere to put it back, so this x
   deletes. */
.vwqr-x.danger { background: var(--bad); }

/* A DOT, NOT A WORD. It marks the references that belong to no comment -
   the ones whose x is a delete rather than an unflag - and at 46px there
   is no room for a label. The tooltip and the aria-label say it in words;
   this is only so the two kinds are told apart at a glance. */
.vwqr-thumb .vwqr-own {
  position: absolute; left: 2px; bottom: 2px;
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent); border: 1px solid var(--bg);
}

/* The + that adds to the shelf, sized and shaped like a thumbnail
   because it takes a thumbnail's place in the row. */
.vwqr-add {
  flex: none; width: 46px; height: 46px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 2px dashed var(--line); border-radius: var(--r-sm);
  background: var(--bg-2); color: var(--ink-2); cursor: pointer;
}
.vwqr-add .ic { width: 20px; height: 20px; }
.vwqr-add:active { border-color: var(--accent); color: var(--accent); }

/* WHAT THIS WINDOW IS, on demand. Scrolls rather than growing, because
   the window has a height somebody chose and four paragraphs of prose is
   not a reason to take it. */
.vwqr-about {
  flex: none; max-height: 55%; overflow-y: auto;
  padding: 8px 10px; background: var(--accent-lo);
  border-top: 1px solid var(--line-2);
  font-size: var(--fs-small); line-height: 1.45; color: var(--ink);
}
.vwqr-about p { margin: 0 0 6px; }
.vwqr-about p:last-child { margin-bottom: 0; }

/* The delete confirm and the failure line, both flow rows at the foot of
   the window. The question sits away from the button that asked it - the
   same reason the annotator's confirm is in its footer: a confirm under
   the finger already on its way down is not a confirm. */
.vwqr-confirm {
  flex: none; display: flex; align-items: center; gap: 8px;
  flex-wrap: wrap; padding: 8px;
  font-size: var(--fs-small); line-height: 1.35; color: var(--ink);
  background: var(--bad-bg); border-top: 1.5px solid var(--bad);
}
.vwqr-confirm span { flex: 1; min-width: 130px; }
.vwqr-confirm .btn { flex: none; }

/* `:empty` hides it, so a window with nothing to say spends no height on
   an empty strip - the same rule .vwmsg uses over the sheet. */
.vwqr-msg {
  flex: none; padding: 7px 8px; font-size: var(--fs-small); line-height: 1.35;
  color: var(--bad); background: var(--bad-bg);
  border-top: 1px solid var(--bad);
}
.vwqr-msg:empty { display: none; }
.vwqr-msg.info { color: var(--ink-2); background: var(--bg-2); border-top-color: var(--line-2); }

/* 26px of grip, not the hairline corner it started as: it is taken hold
   of with a thumb on a moving boat - the same reason .vwthumbs-resizer is
   22px wide rather than the 14px it started at. */
.vwqr-grip {
  position: absolute; right: 0; bottom: 0; width: 26px; height: 26px;
  cursor: nwse-resize; touch-action: none;
}
.vwqr-grip::after {
  content: ''; position: absolute; right: 5px; bottom: 5px;
  width: 12px; height: 12px;
  border-right: 3px solid var(--line);
  border-bottom: 3px solid var(--line);
}
.vwqr-grip:active::after,
.vwqr.sizing .vwqr-grip::after {
  border-right-color: var(--accent); border-bottom-color: var(--accent);
}

/* A SMALL PROMPT OVER THE BAR. Two of them use this: the page-jump field
   and the calibration length. Over the bar rather than in it - a text
   field inside a 52px strip is either too small to type in or makes the
   strip too tall to justify - and fixed to the viewport for the same
   reason every other overlay in this app is: see the containing-block
   note at the top of this file.

   It sits above --kb so a keyboard cannot cover it, and --kb is the
   KEYBOARD inset with a floor under it - not every inset the visual
   viewport reports. See the note on --kb in ui.js.

   ONE RULE FOR BOTH, rather than a second near-identical block. The
   second prompt is what showed that the first one's rules were a
   component and not a one-off. */
.vwprompt {
  position: fixed; z-index: 60;
  left: 50%; transform: translateX(-50%);
  bottom: calc(var(--vwbar-clear, calc(var(--vwbartotal) + max(8px, var(--safe-b)))) + var(--kb, 0px) + 10px);
  width: min(320px, calc(100vw - 24px));
  display: flex; flex-direction: column; gap: 8px;
  padding: 12px; border-radius: var(--r-md);
  background: var(--bg); color: var(--ink);
  border: 1.5px solid var(--line);
  box-shadow: 0 12px 32px rgba(0,0,0,.34);
}
.vwprompt-btns { display: flex; gap: 8px; }
.vwprompt-btns .btn { flex: 1; }
/* A prompt's textarea is a form field like any other, and `label.f >
   textarea` does not reach it - the label is a sibling here, because the
   field has to sit under its own caption rather than inside it. Stated
   rather than inherited; see the note on textareas near label.f. */
.vwprompt textarea {
  width: 100%; box-sizing: border-box; font: inherit; font-size: var(--fs-lead);
  padding: 8px 10px; border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); color: var(--ink); resize: vertical;
}

/* WHAT TO DO WITH WHAT IS SELECTED. Anchored in JS to where the gesture
   ended, and clamped to the window - the same arrangement the visit
   colour popover uses, and for the same reason: `position: fixed` against
   the viewport rather than against any ancestor that might establish a
   containing block. */
/* A DOCKED CONTEXT BAR: the selection menu on a sheet and the edit menu
   on a photo, in the Properties bar's place and format. Fixed against the
   viewport; portal-tools placeDocked sets the three insets from the
   drawing area, the side dock and the Properties bar. */
.dockmenu {
  position: fixed; z-index: 62;
  top: var(--dock-top, 60px); left: var(--dock-l, 0px); right: var(--dock-r, 0px);
  margin: 0 auto; width: fit-content;
  max-width: calc(100vw - var(--dock-l, 0px) - var(--dock-r, 0px) - 16px);
  display: flex; flex-direction: column; align-items: stretch; gap: 5px;
  padding: 5px 6px; border-radius: var(--r-md);
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-2, 0 3px 14px rgba(0,0,0,.28));
}
/* A COMMENT'S MENU stays a popup at the bubble: worded items in a column,
   placed in JS where the hold ended and clamped to the window. */
.vwselmenu.popup {
  position: fixed; z-index: 62;
  left: var(--pop-x, 0); top: var(--pop-y, 0);
  min-width: 190px; max-width: min(280px, calc(100vw - 24px));
  display: flex; flex-direction: column;
  padding: 6px; border-radius: var(--r-md);
  background: var(--bg); color: var(--ink);
  border: 1.5px solid var(--line);
  box-shadow: 0 12px 32px rgba(0,0,0,.3);
}
.vwselmenu.popup button {
  display: block; width: 100%; text-align: left;
  min-height: 42px; padding: 0 12px; border: 0; border-radius: var(--r-sm);
  background: none; color: var(--ink);
  font: inherit; font-size: var(--fs-field); cursor: pointer;
}
.vwselmenu.popup button:active { background: var(--accent-lo); }
.vwselmenu.popup .vwselmenu-cap { text-align: left; white-space: normal; padding: 5px 12px 6px; }
.vwselmenu.popup .vwselmenu-cap + button { border-top: 1px solid var(--line-2); border-radius: 0 0 8px 8px; }

/* What is selected, and what a measurement reads - a caption rather than
   an action, because it is not something to press. */
.vwselmenu-cap {
  display: block; padding: 0 6px; max-width: 340px; text-align: center;
  font-size: var(--fs-small); color: var(--ink-2); line-height: 1.35;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dockmenu > .propactions:first-child { border-top: 0; padding-top: 0; }

/* A HANDLE: an end of a two-point mark, or a corner of a snapshot window.
   In the unscaled pin layer so it stays a real tap target at any zoom -
   the same reason a comment bubble lives there rather than in the ink
   overlay. `pointer-events: auto` because `.vwpins` is `none` and the
   value inherits: without it a press on a handle reaches the sheet. */
.vwhandle {
  position: absolute; top: 0; left: 0; pointer-events: auto;
  /* Over a settled bubble: a snapshot's bubble is anchored to the same
     top-right corner its handle sits on. A lifted bubble (2) stays above. */
  z-index: 1;
  /* A 36 px target with a small grip drawn in its middle: big enough for
     a thumb, small enough that the end it sits on stays visible. */
  width: 36px; height: 36px; margin: -18px 0 0 -18px;
  padding: 0; border: 0; border-radius: 50%; background: none;
  cursor: grab; touch-action: none;
}
.vwhandle::before {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%;
  border: 2px solid #fff; background: var(--accent);
  box-shadow: 0 1px 4px rgba(0,0,0,.45);
}
/* No transform here: position is the inline translate alone, the same
   rule `.vwpin.lifted` follows. */
.vwhandle:active { cursor: grabbing; }
.vwhandle:active::before { box-shadow: 0 0 0 4px rgba(255,255,255,.55), 0 1px 4px rgba(0,0,0,.45); }

/* THE DOCK'S OWN COLLAPSE. The arrow sits above the column and turns to
   point the way the dock will go. Collapsed, the dock is just the arrow -
   `width: auto` on a flex column with one visible child - so the drawing
   gets the rest back. */
.dockfold { width: 36px; height: 24px; border-radius: var(--r-sm); opacity: .75; }
.dockfold .ic { width: 16px; height: 16px; transition: transform .16s ease; }
.vwdock.folded .dockfold .ic { transform: rotate(180deg); }
.vwdock.folded .dockcol { display: none; }
.vwdock.folded { padding: 4px; }

/* THE DRAWING SCALE DIALOG. Centred over the sheet rather than anchored
   to the dock: it carries a radio pair, a twenty-two-entry list and a
   calibration flow, which is more than a flyout beside a button can hold
   legibly on a phone.

   `position: fixed` against the viewport, like every other overlay here -
   see the containing-block note at the top of this file - and it sits
   above --kb so the keyboard cannot cover the length field. */
.vwscale {
  position: fixed; z-index: 60;
  left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: min(380px, calc(100vw - 24px));
  max-height: calc(100dvh - var(--kb, 0px) - 48px);
  display: flex; flex-direction: column;
  border-radius: var(--radius); overflow: hidden;
  background: var(--bg); color: var(--ink);
  border: 1.5px solid var(--line);
  box-shadow: 0 16px 44px rgba(0,0,0,.4);
}
.vwscale-head {
  display: flex; align-items: center; gap: 8px; flex: none;
  padding: 10px 12px; background: var(--navy); color: #fff;
}
.vwscale-head strong { font-size: var(--fs-field); flex: none; }
/* Which sheet, and what its scale is now. Truncates rather than pushing
   the close button off - the sheet number is the part that matters. */
.vwscale-sheet {
  flex: 1; min-width: 0; font-size: var(--fs-small); opacity: .8;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The body scrolls, the header and footer do not: a twenty-two-entry
   select plus a calibration flow does not fit a phone in landscape. */
.vwscale-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; }
.vwscale-body .f { margin-top: 10px; }
.vwscale-foot {
  display: flex; gap: 8px; flex: none;
  padding: 10px 12px; border-top: 1px solid var(--line);
  padding-bottom: max(10px, var(--safe-b));
}
.vwscale-foot .btn { flex: 1; }

/* =====================================================================
   THE VISIT COUNTER

   One row of bubbles, one per visit that put a comment on the sheet, in
   the visit's own colour and in sequential order. The SAME markup and the
   same rules on a sheet-index row and on a thumbnail - only the size
   differs - because two counters drawn two ways are two chances to
   disagree about how much of a walk a sheet is carrying.

   Absent rather than zero. A forty-sheet index carrying thirty-eight
   zeroes is thirty-eight things to read past to find the two that matter.
   ===================================================================== */
.cmbadges { display: inline-flex; align-items: center; gap: 3px; }
.cmbadge {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 20px; height: 20px; padding: 0 5px; border-radius: var(--r-md);
  background: var(--visit, var(--accent)); color: #fff;
  font-family: inherit; font-size: var(--fs-small); font-weight: 700; line-height: 1;
}
/* The visits that did not fit, summed. Neutral on purpose - it stands for
   several colours at once, so borrowing any one of them would be a lie. */
.cmbadge.more {
  background: transparent; color: var(--ink-2);
  border: 1px solid var(--line); font-weight: 600;
}

/* Tool dock, right edge. Floats over the sheet rather than taking a strip
   of it: on an iPad the drawing wants every pixel, and a docked column
   sits under the hand that is already holding the device.

   THE SHEET VIEWER HAS ONE AGAIN. This class was written for it, kept
   when the tools moved to the photo annotator, and is now shared by both
   - the annotator adds `.andock` for the scrolling its longer palette
   needs. Both are wired by portal-tools; only the id prefix differs.

   IT SCROLLS AND IT CLEARS THE BOTTOM BAR. A phone in landscape has
   about 320px of height for a nine-button column, so the dock has to be
   able to scroll rather than run off the screen, and its box has to stop
   above the page controls. */
.vwdock {
  /* Vertically centred in whatever box contains it. The VIEWER's dock is
     then shifted up to clear its bottom bar - see .vwdock:not(.andock)
     below, and note that the shift belongs there rather than here
     precisely because the annotator has no such bar. */
  position: absolute; right: 8px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 4; display: flex; flex-direction: column; gap: 4px;
  padding: 5px 4px; border-radius: var(--r-md);
  background: var(--bg); border: 1px solid var(--line);
  box-shadow: 0 3px 14px rgba(0,0,0,.28);
  /* Never taller than the space between the top bar and the bottom one.
     The COLUMN inside is what scrolls - see the .dockcol rule below -
     because `max-height` alone leaves the buttons past the fold simply
     unreachable, and a phone in landscape has about 320px of height for
     an eight-button column. The annotator overrides this with its own
     78%; it has two bars of its own to clear rather than one. */
  /* Plain, like the bar's own padding - see the note there. */
  max-height: calc(100% - var(--vwbartotal) - var(--safe-b) - 88px);
  overflow: hidden;
}
/* The dock is always visible too - it went away with the bar when the bar
   hid itself, and for the same reason it no longer does. */
/* 36px, DOWN FROM 42. Nine buttons plus a fold arrow down the right edge
   of a drawing is a lot of drawing to give up, and this is a TOOL PALETTE
   rather than a set of primary actions: the thing being aimed at is the
   sheet, and the palette is what you reach for between aims. Below the
   48px a standalone control should be, deliberately, and for the same
   reason Bluebeam and Procreate sit around here.

   Shared with the photo annotator, which wants the same thing for the
   same reason - its palette is the longer of the two. */
.dockbtn {
  width: 36px; height: 36px; border-radius: var(--r-sm); padding: 0;
  border: 0; background: none; color: var(--ink); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}
.dockbtn .ic { width: 19px; height: 19px; }
.dockbtn:active { background: var(--accent-lo); }
.dockbtn[aria-pressed="true"] { background: var(--accent-fill); color: var(--on-accent); }
.docksep { height: 1px; background: var(--line); margin: 2px 4px; }

/* The swatch shows the current colour, so its border carries selection
   rather than its fill - a fill change is invisible on the swatch that IS
   that colour. */
.dockbtn.swatch::after {
  content: ''; width: 22px; height: 22px; border-radius: 50%;
  background: var(--ink-swatch, #d21f1f);
  box-shadow: inset 0 0 0 1.5px rgba(0,0,0,.25);
}

/* Popovers sit to the LEFT of the dock, since the dock is on the right
   edge - opening rightwards would put them off screen. */
/* z-index 6: every flyout sits above the dock and takes pointer events.
   The dock is z-index 4 and the stage paints beneath it; a flyout sharing
   the dock's layer could be covered by it at some sizes. This used to be
   5 here and 6 in a second .dockpop block four hundred lines down, so the
   5 was dead and the reason for the 6 was nowhere near it. */
.dockpop {
  position: absolute; right: 62px; z-index: 6; pointer-events: auto;
  background: var(--bg); border: 1px solid var(--line); border-radius: var(--r-md);
  box-shadow: 0 3px 14px rgba(0,0,0,.28); padding: 10px;
}
.dockpop.colours {
  top: 50%; transform: translateY(-50%);
  display: flex; flex-direction: column; gap: 8px;
}
.dockpop.colours button {
  width: 34px; height: 34px; border-radius: 50%; cursor: pointer;
  border: 2px solid var(--line); padding: 0;
}
.dockpop.colours button[data-colour="red"]    { background: var(--ink-red); }
.dockpop.colours button[data-colour="blue"]   { background: var(--ink-blue); }
.dockpop.colours button[data-colour="green"]  { background: var(--ink-green); }
.dockpop.colours button[data-colour="black"]  { background: var(--ink-black); }
.dockpop.colours button[data-colour="yellow"] { background: var(--ink-yellow); }
.dockpop.colours button[aria-pressed="true"] {
  box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent);
}

.dockpop.width {
  top: 50%; transform: translateY(-50%);
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  width: 190px;
}
.widthpreview {
  height: 30px; width: 100%; display: flex; align-items: center;
  justify-content: center;
}
.widthpreview span {
  display: block; border-radius: 999px; background: var(--ink);
  width: 8px; height: 8px;
}
.dockpop.width input[type="range"] { width: 100%; }

/* The eraser needs to read differently before it is used, not after. */
.vwstage.erasing { cursor: cell; }

/* Comment sheet: slides up over the drawing, never covers it entirely.

   THE KEYBOARD INSET
     `--kb` is the height of the iOS software keyboard, tracked from
     visualViewport in ui.js. It has to exist because iOS shrinks the
     VISUAL viewport when the keyboard opens but not the LAYOUT viewport,
     and this panel sits inside `position: fixed; inset: 0`, which is sized
     to the layout viewport. Without the inset, `bottom: 0` put the footer -
     Save, Delete, Cancel - underneath the keyboard on every iPhone: a note
     could be typed and then not saved. Defaults to 0, so nothing changes
     on a device with no software keyboard. */
.vwsheet {
  position: absolute; left: 0; right: 0; z-index: 5;
  bottom: var(--kb, 0px);
  /* 82% of what is LEFT above the keyboard, not 82% of the panel with the
     keyboard then subtracted. Two moving terms in one box is how a height
     ends up depending on the inset twice; this is one expression, and with
     the keyboard closed it is exactly the 82% it always was. */
  max-height: calc((100% - var(--kb, 0px)) * 0.82);
  display: flex; flex-direction: column;
  background: var(--bg); border-top: 2px solid var(--accent);
  border-radius: 14px 14px 0 0; box-shadow: 0 -4px 20px rgba(0,0,0,.3);
}
/* THE SHEET GROWS IN AND DECAYS OUT, vertically, from its own bottom
   edge - it is a bottom sheet, so that is the edge it is hinged on.

   `.tucked` IS BOTH THE FROM AND THE TO. Opening adds it, unhides, forces
   a frame and removes it; closing adds it and hides once the transition
   has run. One state, two directions, and nothing to keep in step.

   THE TRANSFORM IS ONLY PRESENT DURING THE ANIMATION. A transform takes
   the viewport away as the containing block for any fixed descendant, and
   although nothing inside this sheet is fixed today, leaving one on
   permanently is how that becomes true by accident later - the same
   reasoning as `.vwqr.animating`, and the same shape. At rest this box
   carries no transform at all.

   scaleY ALONE, not `scale`. A uniform scale narrows the sheet as it
   grows, which reads as it arriving from a distance rather than rising
   from the bottom of the screen. */
.vwsheet.tucked { transform: translateY(10px) scaleY(.96); opacity: 0; }
.vwsheet.animating {
  transform-origin: bottom center;
  transition: transform 190ms cubic-bezier(.2, .7, .3, 1), opacity 150ms ease;
  will-change: transform;
}
/* SOMEBODY WHO HAS ASKED FOR LESS MOTION GETS NONE. The sheet still opens
   and still closes; it simply arrives. */
@media (prefers-reduced-motion: reduce) {
  .vwsheet.animating { transition: none; }
}

/* THE HEAD IS THE WHOLE CONTROL SET now that the footer is gone: delete
   on the left, title in the middle, Done on the right. The title takes
   the slack, so the two controls sit at the extremes however long the
   title is - which is the point. Putting a destructive control next to
   the button tapped every time is how a finding gets lost to muscle
   memory; the old footer answered that with a colour, and opposite ends
   of one bar answer it better. */
.vwsheet-head {
  display: flex; align-items: center; gap: 10px;
  padding: 12px 14px; border-bottom: 1px solid var(--line);
}
.vwsheet-head strong { flex: 1; min-width: 0; font-size: var(--fs-lead); }
/* INFORMATIONAL, beside Done on both editor surfaces. A real checkbox
   with the whole label as the target, sized for a thumb like .checklist -
   and `flex: none` so it never takes room from the title, which is what
   min-width: 0 on the title is there to give up.
   The word is hidden on a narrow phone, where the head already carries a
   trashcan, a title and Done: the box and its accessible name remain, so
   nothing is lost but the visible word. */
/* 38px to match `.btn.mini` beside it, NOT --tap. The head's whole
   argument is that it costs one row instead of two, and a 48px control
   in it grows that row on the phone where the room matters most. The
   label is the target, so the tappable area is the row rather than the
   20px box. */
.vwc-info {
  display: inline-flex; align-items: center; gap: 6px; flex: none;
  min-height: 38px; padding: 0 2px; cursor: pointer;
  font-size: var(--fs-note); color: var(--ink-2); white-space: nowrap;
}
.vwc-info input[type="checkbox"] {
  width: 20px; height: 20px; flex: none; accent-color: var(--accent);
}
.vwc-info:has(input:checked) { color: var(--accent); font-weight: 600; }
@media (max-width: 420px) {
  .vwsheet-head .vwc-info span { display: none; }
}
.vwbtn.dark { background: var(--bg-2); color: var(--ink); }
/* THE CONFIRM, in its own row under the head. Its Delete is at the far
   right and the trashcan that opens it is at the far left, so the
   confirming tap cannot land where the finger already is. Present only
   while the question is being asked. */
.vwc-delbar {
  display: flex; align-items: center; gap: 8px; flex: none;
  padding: 10px 14px;
  background: var(--bad-bg); border-bottom: 1px solid var(--line);
}
.vwc-delbar span { flex: 1; min-width: 0; font-size: var(--fs-body); color: var(--bad); font-weight: 600; }
.vwc-delbar .btn { flex: none; }
/* `.vwbtn`'s own fill is white-on-translucent-white, which is right in the
   navy app bar it was written for and invisible on this head, which sits
   on `--bg`. The X here carried `.dark` for exactly this reason. */
.vwsheet-head .vwbtn { background: var(--bg-2); color: var(--ink); }
/* Tinted rather than filled at rest: it has to read as destructive
   without shouting over the note. Filled solid once ARMED, where being
   unmistakable is the entire job. */
.vwsheet-head .vwbtn.danger { background: var(--bad-bg); color: var(--bad); }
.vwsheet-head .vwbtn.danger:active { background: var(--bad-fill); color: #fff; }
.vwsheet-body {
  padding: 12px 14px; overflow-y: auto; flex: 1; min-height: 0;
  /* THE HOME INDICATOR, which the footer used to clear. With the footer
     gone the body is the last thing in the sheet, so the inset moves
     here or the final line of a note sits under the indicator. */
  padding-bottom: max(12px, env(safe-area-inset-bottom));
}
.vwsheet-body textarea {
  width: 100%; font: inherit; font-size: var(--fs-lead); padding: 10px;
  border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); color: var(--ink); resize: vertical;
}
/* `.vwsheet-foot` is GONE, with its three buttons. Delete and Done live
   in the head now and Discard was the same act as Delete - see the note
   on the head above, and the markup where the footer was. The home
   indicator inset moved onto `.vwsheet-body`, which is the last thing in
   the sheet now. */

/* Photos */
.photostrip { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0; }
.photothumb {
  /* `relative` is required, not cosmetic: the ink overlay and the markup
     and snapshot badges are absolutely positioned inside this. */
  position: relative;
  width: 72px; height: 72px; border-radius: var(--r-sm); overflow: hidden;
  border: 1.5px solid var(--line); background: var(--bg-2);
}
.photothumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

.photocell {
  position: relative; aspect-ratio: 1; border: 1.5px solid var(--line);
  border-radius: var(--r-sm); overflow: hidden; background: var(--bg-2);
  padding: 0; cursor: pointer;
}
.photocell img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* =====================================================================
   THE COMMENT EDITOR'S MEDIA ROW

   Two sections under the note: the comment's ONE window on the drawing,
   and its photographs. It replaced a wrapping strip plus two full-width
   buttons stacked beneath it - four rows of column on a phone, for two
   actions that an empty slot and a plus tile perform by existing.

   THE SNAPSHOT SLOT DOES NOT GROW AND THE PHOTOS SCROLL. A snapshot is
   one thing and always the same size, so it takes a fixed column; photos
   are a list of unknown length, so they get the remaining width and
   scroll inside it. Giving both `flex: 1` put the slot at half width on a
   phone and left the photos too narrow to identify.

   `overflow-x: auto` ON THE STRIP, NOT ON THE ROW. The row is a flex
   parent of a scroller, and `min-width: 0` on the scrolling child is what
   actually lets it be narrower than its content - without it a flex item
   refuses to shrink below its intrinsic width and the whole panel scrolls
   sideways instead. This is the same trap `.vwstage` carries a note about.

   NO `overflow` ANYWHERE ELSE HERE. references/ui-and-layout.md: overflow
   on an ancestor silently breaks every `position: fixed` descendant, and
   the comment sheet has fixed children.
   ===================================================================== */
.cmedia {
  display: flex; align-items: flex-start; gap: 12px; margin: 10px 0;
}
.cmedia-sec { display: flex; flex-direction: column; gap: 4px; }
.cmedia-snapsec { flex: none; }
.cmedia-photosec { flex: 1; min-width: 0; }
.cmedia-cap {
  font-size: var(--fs-small); font-weight: 600; color: var(--ink-2);
}
.cmedia-slot { display: flex; }
.cmedia-strip {
  display: flex; gap: 8px; align-items: center;
  overflow-x: auto; min-width: 0;
  /* A scroller that is also a row of tap targets: keep the momentum but
     stop it stealing a vertical drag from the panel behind it. */
  touch-action: pan-x;
  padding-bottom: 2px;
}
/* Every tile in the row is the same square, so the plus reads as one more
   slot in the same series rather than as a button that wandered in. */
.cmedia-slot > *, .cmedia-strip > * { flex: none; }
.cmedia-add, .cmedia-empty {
  width: 72px; height: 72px; border-radius: var(--r-sm);
  border: 1.5px dashed var(--line); background: var(--bg-2);
  display: flex; align-items: center; justify-content: center;
  color: var(--ink-2); cursor: pointer;
  padding: 0; font: inherit;   /* Take photo is a <button> */
}
.cmedia-add:active, .cmedia-empty:active {
  border-color: var(--accent); color: var(--accent);
}
/* Dashed for an empty slot, solid once it holds something - the same
   distinction the snapshot frame makes on the drawing between a reference
   and a thing that is there. */
.cmedia-empty { flex-direction: column; gap: 3px; font-size: var(--fs-micro); }
/* BOTH TILES SIZE THEIR ICON, and it is not optional. `svg.ic` up at the
   top of this file drives every icon's colour and sets no dimensions - so
   every call site gives it a box, and the one that forgot got an SVG with
   a viewBox and no intrinsic size inside a centred flex tile, which
   stretches to fill it. The plus came out 72px square, as big as the
   thumbnail beside it. The plus is larger than the crop because it is the
   only thing in its tile; the snapshot tile shares its space with a label. */
.cmedia-empty .ic { width: 20px; height: 20px; }
.cmedia-add .ic { width: 24px; height: 24px; }
/* A snapshot in the slot is NOT a draw target, so it does not get the
   pressed-to-edit affordance the photo thumbnails have. It opens to be
   looked at; the ink for it goes on the drawing. */
.cmedia-slot .photothumb { cursor: zoom-in; }
/* =====================================================================
   Photo viewer

   Centred over whatever you were reading, rather than appended to the
   bottom of the comments list - tapping a photo half way down a long log
   used to put it below the fold, so you had to go and find it.

   z-index 55 sits above the app bar and the tab bar (50) and below the
   About sheet (60). The annotator opens over the top of this at 40 within
   <main>, but this lives outside <main> and above it, so Draw on photo
   hands over cleanly.
   ===================================================================== */
.phtmodal {
  position: fixed; inset: 0; z-index: 55;
  display: flex; align-items: center; justify-content: center;
  padding: max(12px, env(safe-area-inset-top, 0px)) 12px
           max(12px, env(safe-area-inset-bottom, 0px));
}
.phtscrim {
  position: absolute; inset: 0;
  background: rgba(0, 0, 0, .55);
}
.phtbox {
  position: relative;             /* over the scrim */
  width: min(560px, 100%);
  /* The box scrolls, not the page behind it. A tall photo plus four
     buttons does not fit a phone in landscape, and the alternative is
     buttons you cannot reach. */
  max-height: 100%; overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: 0 8px 30px rgba(0, 0, 0, .38);
  padding: 14px;
}
.phtbox h2 { margin-top: 0; }

/* THE BOX HUGS THE IMAGE, rather than the image filling the box.

   `.photoink` is `inset: 0` over `.photofull` and its SVG is
   `preserveAspectRatio="none"`, so it stretches to whatever that box is.
   Letterboxing the picture inside a wider box - which is what
   `object-fit: contain` does - would leave the markup stretched across the
   bars and no longer over what it was drawn on. Sizing the box to the
   image keeps the two in register by construction.

   The height cap is against the VIEWPORT, so a tall portrait photo cannot
   push the buttons off a short screen. */
.phtbox .photofull {
  width: fit-content; max-width: 100%;
  margin-left: auto; margin-right: auto;
}
.phtbox .photofull img {
  width: auto; max-width: 100%;
  height: auto; max-height: 58vh;
}

.photofull {
  /* `relative` positions the ink overlay - see .photofull .photoink. */
  position: relative;
  margin-top: 10px; border-radius: var(--r-sm); overflow: hidden;
  background: var(--bg-2); border: 1px solid var(--line);
}
.photofull img { width: 100%; height: auto; display: block; }

/* =====================================================================
   The comment log

   Narrow on purpose. A field list that wraps to three lines shows four
   findings a screen; clipping the note to one line and opening it on tap
   shows a dozen. The clip is `text-overflow: ellipsis`, which needs the
   flex child to be allowed to shrink - hence `min-width: 0`, without
   which a long note simply pushes the row wider than the phone.
   ===================================================================== */

.cm-log { margin-top: 10px; }

.cm-group {
  display: flex; align-items: center; gap: 8px;
  margin: 16px 0 6px; padding-bottom: 5px;
  border-bottom: 1.5px solid var(--line);
}
.cm-group:first-child { margin-top: 6px; }
.cm-group strong { font-size: var(--fs-body); }
/* Coloured by visit, like every other bubble that stands for one - and it
   is a BUTTON now: this is where the visit's colour is chosen, because
   the log is the one screen that lists every visit at once and the swatch
   already stands for the visit everywhere else.

   `border: 0` and `padding: 0` are not decoration. It was a <span>, so
   nothing here undid the UA's button chrome, and a 22px circle with a
   default border and padding comes out an oval with the digit clipped.
   The lesson from `.vwfilter` generalises: a control re-dressed for a new
   context inherits its old context's box, and a bubble that was never a
   control has no box to inherit at all. */
.cm-visit {
  display: inline-flex; align-items: center; justify-content: center;
  flex: none; width: 22px; height: 22px; border-radius: 50%;
  border: 0; padding: 0; margin: 0;
  background: var(--visit, var(--accent)); color: #fff;
  font: inherit; font-size: var(--fs-small); font-weight: 700;
  cursor: pointer;
  /* A hairline so a dark swatch on a dark ground still reads as a chip
     you can press, and so the ring is visible at either scheme. */
  box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.35);
}
.cm-visit:active { transform: scale(.94); }

/* THE VISIT COLOUR PICKER. A popover rather than a screen: choosing a
   colour is a two-second decision made while looking at the thing it
   applies to, and a settings page for it would be a page nobody opens.

   Anchored in JS to the swatch that opened it, so it appears next to the
   visit it changes - the position is computed, which is why the offsets
   are custom properties rather than declarations. */
.colourpop {
  position: fixed; z-index: 60;
  left: var(--pop-x, 0); top: var(--pop-y, 0);
  display: flex; flex-direction: column; gap: 8px;
  padding: 10px; border-radius: var(--r-md);
  background: var(--bg); border: 1.5px solid var(--line);
  box-shadow: 0 12px 32px rgba(0,0,0,.24);
}
.colourpop-row { display: flex; gap: 6px; flex-wrap: wrap; max-width: 216px; }
.colourpop-row button {
  width: 34px; height: 34px; border-radius: 50%; flex: none;
  border: 2px solid transparent; padding: 0; cursor: pointer;
  background: var(--swatch, var(--accent));
  box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.35);
}
.colourpop-row button[aria-pressed="true"] { border-color: var(--ink); }
.colourpop-foot { display: flex; gap: 6px; }
.colourpop-foot .btn { flex: 1; }
.colourpop .note { margin-top: 0; max-width: 216px; }
.cm-day { font-size: var(--fs-note); color: var(--ink-2); }
.cm-count { margin-left: auto; font-size: var(--fs-small); color: var(--ink-2); }

.cm-item { border-bottom: 1px solid var(--line); }
.cm-row { display: flex; align-items: stretch; gap: 0; }

.cm-line {
  flex: 1; min-width: 0;                 /* lets the note clip, not push */
  display: flex; align-items: center; gap: 8px;
  padding: 9px 4px; border: 0; background: none; color: inherit;
  font: inherit; text-align: left; cursor: pointer;
}
.cm-line:active { background: var(--bg-2); }

.cm-bub {
  display: inline-flex; align-items: center; justify-content: center;
  flex: none; width: 22px; height: 22px; border-radius: 50%;
  /* The severity colour, filled like the bubble on the drawing. */
  background: var(--sev, var(--accent)); color: #fff;
  font-size: var(--fs-cap); font-weight: 700;
}
.cm-sheet {
  flex: none; font-size: var(--fs-small); font-weight: 700; color: var(--ink-2);
  min-width: 46px;
}
.cm-sheet.cm-general { font-weight: 500; font-style: italic; }
.cm-note {
  flex: 1; min-width: 0; font-size: var(--fs-body);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cm-note.cm-empty { color: var(--ink-2); font-style: italic; }
.cm-pics {
  flex: none; font-size: var(--fs-cap); font-weight: 700; color: var(--ink-2);
  border: 1px solid var(--line); border-radius: var(--r-md); padding: 1px 6px;
}

/* The + keeps the 48px target the rest of the app promises for gloves,
   without a 48px-tall row - the padding does the work, not the glyph. */
.cm-add {
  flex: none; width: 44px; border: 0; background: none;
  color: var(--accent); cursor: pointer; border-radius: var(--r-sm);
  display: inline-flex; align-items: center; justify-content: center;
}
.cm-add .ic { width: 22px; height: 22px; stroke-width: 2; }
.cm-add:active { background: var(--accent-lo); }

.cm-body { padding: 2px 4px 12px; }
.cm-meta {
  display: flex; gap: 12px; flex-wrap: wrap;
  font-size: var(--fs-small); color: var(--ink-2); margin: 2px 0 8px;
}
.cm-body textarea {
  width: 100%; font: inherit; font-size: var(--fs-lead); padding: 8px;
  border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); color: var(--ink); resize: vertical;
}
.cm-body .photostrip .photocell { width: 72px; height: 72px; aspect-ratio: auto; }

/* Focus has to be visible somewhere. Every button in the app had an
   :active style and no focus style at all, which makes keyboard use -
   reviewing a walk at a desk - invisible. */
/* NO border-radius HERE. At this specificity it beat every equal rule
   declared above it, so a focused round control - a comment pin, a visit
   swatch, the page bubbles that used to float over the sheet - stopped
   being a circle and every button snapped to 4px, while the rules
   declared BELOW this one were unaffected. That is why it looked
   arbitrary rather than uniformly wrong. `outline` already follows the
   element's own radius. */
:focus-visible {
  outline: 3px solid var(--accent); outline-offset: 2px;
}

/* Pins are the primary field interaction and were a 28px target. The box
   stays 28px; the tappable area does not. */
.vwpin::before { content: ''; position: absolute; inset: -9px; }

/* =====================================================================
   Utility classes that replace inline style attributes

   The deployed CSP is `style-src 'self'` with no 'unsafe-inline', and a
   style ATTRIBUTE is checked against style-src-attr, which falls back to
   style-src - so every literal style="..." in the markup is discarded by
   the browser and logged as a violation. These exist so new markup has
   somewhere to put the three declarations that kept reaching for one.
   ===================================================================== */

table.tbl th.l, table.tbl td.l { text-align: left; }
.f-wide { grid-column: 1 / -1; }
.dim { opacity: .6; }
.gap-t { margin-top: 10px; }
.gap-b { margin-bottom: 10px; }
.flush-t { margin-top: 0; }

/* Comment count on a sheet row. Scanned down the right-hand edge of a
   forty-sheet index to see where a walk actually went, and it is what the
   export selects on - so it reads as a count, not as decoration. */
.sh-open .v {
  display: inline-flex; align-items: center; gap: 8px;
  flex: none; font-family: var(--mono); font-size: var(--fs-note); color: var(--ink-2);
}
/* The sheet-index counter is the shared .cmbadge row - see it above. This
   is kept only so an older cached stylesheet does not leave a badge
   unstyled while the service worker catches up.
   DELETE AFTER 2026-11-01: by then no installed phone can still be
   carrying a stylesheet that old. Without a date this outlives its
   reason and reads as a live rule. */
.sh-cm {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 20px; height: 20px; padding: 0 6px; border-radius: var(--r-md);
  background: var(--visit, var(--accent)); color: #fff;
  font-family: inherit; font-size: var(--fs-small); font-weight: 700;
}

/* =====================================================================
   Photo annotator

   The picture is the work surface, so it gets the whole panel. The
   overlay is positioned onto the fitted image rather than the stage: the
   image is letterboxed inside the stage, and ink drawn against the stage
   would sit beside the photograph rather than on it.
   ===================================================================== */

/* =====================================================================
   A DRAWING SURFACE MUST NOT BEHAVE LIKE TEXT

   A stroke on the photo was being read as a text selection AND as a long
   press: the browser highlighted whatever the drag reached - the Done
   button in the bar above, most visibly - and raised its own callout
   menu. Both then cancel the pointer sequence, so the stroke in progress
   was dropped. On a slow, deliberate line, which is exactly the kind you
   draw when being careful, it happened constantly.

   `touch-action: none` does NOT prevent this. It stops the browser
   claiming the gesture for scrolling and zooming; it says nothing about
   selection or the callout, which are separate mechanisms with separate
   properties.

   The rule goes on the whole panel, not the stage, because a drag that
   starts on the stage extends its selection into the bar - which is why
   the button lit up rather than the picture. Inputs are exempted below:
   the comment sheet lives inside one of these panels, and a note you
   could not select or edit would be a worse bug than the one this fixes.
   ===================================================================== */
.panel.viewer {
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none;
}
.panel.viewer input,
.panel.viewer textarea,
.panel.viewer select {
  -webkit-user-select: text; user-select: text;
  -webkit-touch-callout: default;
}

.anstage {
  position: relative; flex: 1; min-height: 0; overflow: hidden;
  background: #10161d; touch-action: none;
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none;
}
/* The zoom/pan wrapper. Both the photo and the ink live inside it, so one
   transform moves them together - transforming them separately is how ink
   ends up a pixel off the thing it is circling at some zoom levels. */
.anpan {
  position: absolute; inset: 0;
  transform-origin: 0 0;
  will-change: transform;
  touch-action: none;
}
#an-img {
  position: absolute; display: block; user-select: none;
  -webkit-user-drag: none; pointer-events: none;
  -webkit-touch-callout: none;
}
/* NOTHING IN THE DOCUMENT IS SELECTABLE WHILE THE ANNOTATOR IS OPEN.

   The `.panel.viewer` rule above covers the annotator and its bar. It does
   not cover the app header, the bottom tab bar, or the dozen other panels
   still sitting in the document behind the open one - and that is where
   the stray highlight was landing. iOS would start a selection out there
   during a stroke, raise its "Copy / Look Up / Translate" callout over the
   drawing surface, and cancel the pointer sequence, taking the line with
   it. A text-selection menu appearing while you write is the visible tell.

   ON <html>, NOT <body>. WebKit will root a selection at the document
   element, which a rule on body does not reach.

   HELD FOR THE WHOLE TIME THE ANNOTATOR IS OPEN, not for the length of a
   gesture. WebKit reads selectability when the touch lands, before any
   pointerdown handler runs, so a class toggled during pointerdown is
   always a beat late - it looked correct in the test suite and did
   nothing at all on the glass. Not permanent either, because results
   elsewhere in the app are meant to stay selectable and copyable.

   This is necessary and NOT sufficient: on current iOS the callout can
   appear regardless, which is what the touch-event guard in
   portal-annotate.js is for. */
/* AND THE SAME FOR THE SHEET VIEWER, under its own class.

   Everything above applies word for word to direct markup on a drawing,
   and the sheet did not have it - so palm rejection existed there as a
   predicate in portal-core and not as behaviour on glass. `.panel.viewer
   { user-select: none }` further up is not a substitute: it reaches
   neither the document element nor the app header and tab bar still
   sitting in the document behind the viewer, which is exactly where the
   stray highlight was landing.

   A SEPARATE CLASS ON PURPOSE. Photo markup opens from the sheet viewer
   and closes back onto it, and portal-annotate clears `aninking` on
   close - so one shared flag would mean marking up a photo disarmed the
   sheet underneath it. Two flags, two owners, neither able to turn the
   other off. */
html.aninking, html.aninking *,
html.vwinking, html.vwinking * {
  -webkit-user-select: none !important; user-select: none !important;
  -webkit-touch-callout: none !important;
}
/* The label editor is a real textarea and has to stay editable, and so are
   the viewer's own fields - the jump box, the scale dialog, and the
   comment sheet's note, which is inside the viewer panel. */
html.aninking .an-liveedit,
html.aninking input,
html.aninking textarea,
html.vwinking input,
html.vwinking textarea,
html.vwinking select {
  -webkit-user-select: text !important; user-select: text !important;
  -webkit-touch-callout: default !important;
}

/* THE INK OVERLAY IS NOT TEXT, even where it contains <text>.

   The "Copy · Look Up · Translate · Search Web" menu is the TEXT
   SELECTION callout, and it kept appearing mid-stroke because an SVG
   <text> node is selectable content like any other. Writing across a
   label selected it, Safari raised its menu, and the pointer sequence was
   cancelled - so the stroke vanished.

   `user-select: none` on the panel was not enough: it does not reach into
   an SVG subtree reliably, and the text nodes carry `pointer-events:
   auto` so they can be tapped for editing. Every element inside the
   overlay is therefore made unselectable explicitly. */
.anoverlay { position: absolute; pointer-events: none; touch-action: none; }
.anoverlay,
.anoverlay * {
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none;
}
.anoverlay text { cursor: pointer; }
#an-pan, #an-pan * {
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none;
}
.anstage.erasing { cursor: cell; }
/* The selection's grips over the photo: the sheet's .vwhandle, in an
   unscaled layer the stage hit-tests by distance (C.gripAt). */
.angrips { position: absolute; inset: 0; pointer-events: none; }
#an-count { font-size: var(--fs-small); opacity: .85; }
.vwbar .btn.mini { flex: none; }

/* =====================================================================
   The annotator palette

   Two columns: quick selects on the left, tools on the right, flyouts
   opening to the left of both.

   IT SCROLLS RATHER THAN GROWING OFF THE SCREEN. A tall iPad in portrait
   has room for a dozen quick selects; a phone in landscape has room for
   four. A dock that grew with the palette would put its bottom controls
   past the edge of the glass, unreachable, with nothing to say why.
   ===================================================================== */

.vwdock.andock {
  flex-direction: row; align-items: stretch; gap: 0; padding: 0;
  max-height: 78%; overflow: hidden;
}
/* THE COLUMN IS WHAT SCROLLS, in either dock. Written for the
   annotator's longer palette and now shared with the sheet's, because
   both can outgrow a phone in landscape and the answer is the same.
   `overscroll-behavior: contain` stops a flick that reaches the end of
   the column turning into a page bounce over the drawing; the scrollbar
   is hidden because it is a strip of chrome over a drawing. */
/* CENTRED ON THE STAGE, NOT ON THE PANEL - but only in the VIEWER. The
   viewer's panel ends in the bottom bar, so a dock at the panel's 50%
   sits half a bar too low and its lowest buttons land behind the page
   controls. The annotator's panel has no such row, and shifting its dock
   by the viewer's bar would have moved it up for a bar that is not on
   screen. */
.vwdock:not(.andock) {
  /* Plain `var(--safe-b)` - see the note on .vwbottom's padding. */
  top: calc(50% - (var(--vwbartotal) + var(--safe-b)) / 2);
}

.vwdock .dockcol {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 4px 2px; overscroll-behavior: contain;
  scrollbar-width: none;
  /* BOTH AXES, STATED. `overflow-y: auto` alone leaves overflow-x at
     `visible`, and the spec says a `visible` computes to `auto` when the
     other axis is not visible - so the column of icons could be scrolled
     sideways, by a hair, for no reason and with nothing there to reach.
     Naming both is the fix; naming only the one you want is the bug. */
  overflow-y: auto; overflow-x: hidden;
}
.vwdock .dockcol::-webkit-scrollbar { display: none; }
.vwdock .docksep { width: 100%; }

/* =====================================================================
   THE VIEWER'S DOCK ON ANY EDGE

   placeDock (portal-viewer) sets --dock-x, --dock-y and --dock-max from
   C.dockPlacement; until it has, the rules above put the dock on the right
   as before. Scoped to #vw-dock: the annotator's dock does not move.
   ===================================================================== */
#vw-dock.placed {
  left: var(--dock-x, auto); top: var(--dock-y, 50%); right: auto;
  transform: none;
  max-height: var(--dock-max, none);
}
#vw-dock.placed.horizontal {
  flex-direction: row; align-items: center;
  max-height: none; max-width: var(--dock-max, none);
  padding: 4px 5px;
}
#vw-dock.horizontal .dockcol {
  flex-direction: row; padding: 2px 4px;
  overflow-x: auto; overflow-y: hidden;
}
/* A capped row scrolls; its buttons must not be squeezed to fit instead. */
#vw-dock.horizontal .dockcol > * { flex: none; }
#vw-dock.horizontal .docksep { width: 1px; height: 24px; margin: 4px 2px; flex: none; }
#vw-dock.horizontal .dockfold { width: 24px; height: 36px; flex: none; }
/* The fold arrow points at the edge the dock collapses toward. */
#vw-dock[data-side="left"] .dockfold .ic { transform: rotate(180deg); }
#vw-dock[data-side="left"].folded .dockfold .ic { transform: none; }
#vw-dock[data-side="top"] .dockfold .ic { transform: rotate(-90deg); }
#vw-dock[data-side="top"].folded .dockfold .ic { transform: rotate(90deg); }
#vw-dock[data-side="bottom"] .dockfold .ic { transform: rotate(90deg); }
#vw-dock[data-side="bottom"].folded .dockfold .ic { transform: rotate(-90deg); }

/* Lifted: it follows the finger over the ghosts, above everything. */
#vw-dock.moving {
  z-index: 9; cursor: grabbing; touch-action: none;
  transform: translate(var(--dock-dx, 0px), var(--dock-dy, 0px)) scale(1.04);
  box-shadow: 0 12px 34px rgba(0, 0, 0, .42);
}
#vw-dock.landing {
  transform: translate(var(--dock-dx, 0px), var(--dock-dy, 0px));
  transition: transform .24s var(--ease-out);
}
@media (prefers-reduced-motion: reduce) { #vw-dock.landing { transition: none; } }
#p-viewer.dockmoving .dockpop { visibility: hidden; }

/* The drawing goes out of focus and each place the dock can land is
   outlined; the one nearest the finger is lit. */
.vwdockghosts {
  position: absolute; inset: 0; z-index: 8; pointer-events: none;
  background: rgba(15, 19, 24, .34);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  animation: dockghostsin .16s var(--ease-out);
}
@keyframes dockghostsin { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .vwdockghosts { animation: none; } }
.vwdockghost {
  position: absolute; box-sizing: border-box;
  left: var(--gx); top: var(--gy); width: var(--gw); height: var(--gh);
  border: 2px dashed rgba(255, 255, 255, .7); border-radius: var(--r-md);
  background: rgba(255, 255, 255, .08);
  transition: background-color .12s var(--ease-out), border-color .12s var(--ease-out);
}
.vwdockghost.near {
  border-style: solid; border-color: var(--brand-sky);
  background: rgba(197, 213, 234, .3);
}

/* THE STRIP FOLLOWS THE DOCK. It comes out of the dock's inner edge
   (C.attachedStripRect), so its tucked, flat side and its slide-out
   direction mirror for the left, and it lays out as a row for top and
   bottom. */
#p-viewer[data-dock-side="left"] .dockpop.attached {
  border-radius: 0 14px 14px 0; border-right: 1px solid var(--line); border-left: 0;
  box-shadow: 6px 4px 22px rgba(0, 0, 0, .22); transform-origin: left center;
}
#p-viewer[data-dock-side="left"] .dockpop.attached.strip { padding: 10px 4px 10px 16px; }
#p-viewer[data-dock-side="top"] .dockpop.attached,
#p-viewer[data-dock-side="bottom"] .dockpop.attached {
  border-right: 1px solid var(--line);
  flex-direction: row; overflow-x: auto; overflow-y: hidden;
}
#p-viewer[data-dock-side="top"] .dockpop.attached {
  border-radius: 0 0 14px 14px; border-top: 0;
  box-shadow: 0 6px 22px rgba(0, 0, 0, .22); transform-origin: center top;
}
#p-viewer[data-dock-side="top"] .dockpop.attached.strip { padding: 16px 10px 4px; }
#p-viewer[data-dock-side="bottom"] .dockpop.attached {
  border-radius: 14px 14px 0 0; border-bottom: 0;
  box-shadow: 0 -6px 22px rgba(0, 0, 0, .22); transform-origin: center bottom;
}
#p-viewer[data-dock-side="bottom"] .dockpop.attached.strip { padding: 4px 10px 16px; }
#p-viewer[data-dock-side="top"] .dockpop.attached.strip > :first-child,
#p-viewer[data-dock-side="bottom"] .dockpop.attached.strip > :first-child { margin-top: 0; margin-left: auto; }
#p-viewer[data-dock-side="top"] .dockpop.attached.strip > :last-child,
#p-viewer[data-dock-side="bottom"] .dockpop.attached.strip > :last-child { margin-bottom: 0; margin-right: auto; }
#p-viewer[data-dock-side="top"] .stripdiv,
#p-viewer[data-dock-side="bottom"] .stripdiv { width: 1px; height: 24px; margin: 0 2px; }
#p-viewer[data-dock-side="top"] .stripsegs,
#p-viewer[data-dock-side="bottom"] .stripsegs { flex-direction: row; }
#p-viewer[data-dock-side="top"] .stripseg,
#p-viewer[data-dock-side="bottom"] .stripseg { min-height: 34px; }
/* Upright on a row: the pill lies along the strip and its word reads level. */
#p-viewer[data-dock-side="top"] .stripsegtoggle,
#p-viewer[data-dock-side="bottom"] .stripsegtoggle { width: 112px; height: 34px; }
#p-viewer[data-dock-side="top"] .stripsegtoggle.stripsegshort,
#p-viewer[data-dock-side="bottom"] .stripsegtoggle.stripsegshort { width: 86px; }
#p-viewer[data-dock-side="top"] .stripsegrot,
#p-viewer[data-dock-side="bottom"] .stripsegrot { transform: translate(-50%, -50%); }

#p-viewer[data-dock-side="left"] .dockpop.attached.grow { animation-name: panelgrow-l; }
#p-viewer[data-dock-side="left"] .dockpop.attached.shrink { animation-name: panelshrink-l; }
#p-viewer[data-dock-side="top"] .dockpop.attached.grow { animation-name: panelgrow-t; }
#p-viewer[data-dock-side="top"] .dockpop.attached.shrink { animation-name: panelshrink-t; }
#p-viewer[data-dock-side="bottom"] .dockpop.attached.grow { animation-name: panelgrow-b; }
#p-viewer[data-dock-side="bottom"] .dockpop.attached.shrink { animation-name: panelshrink-b; }
@keyframes panelgrow-l { from { transform: translateX(calc(-100% + 24px)); opacity: .2; } to { transform: none; opacity: 1; } }
@keyframes panelshrink-l { from { transform: none; opacity: 1; } to { transform: translateX(calc(-100% + 12px)); opacity: 0; } }
@keyframes panelgrow-t { from { transform: translateY(calc(-100% + 24px)); opacity: .2; } to { transform: none; opacity: 1; } }
@keyframes panelshrink-t { from { transform: none; opacity: 1; } to { transform: translateY(calc(-100% + 12px)); opacity: 0; } }
@keyframes panelgrow-b { from { transform: translateY(calc(100% - 24px)); opacity: .2; } to { transform: none; opacity: 1; } }
@keyframes panelshrink-b { from { transform: none; opacity: 1; } to { transform: translateY(calc(100% - 12px)); opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  #p-viewer[data-dock-side] .dockpop.attached.grow,
  #p-viewer[data-dock-side] .dockpop.attached.shrink { animation: none; }
}

/* The palette and weight editor carry their caret on the side facing the strip. */
.strippop.edge-left::after { right: auto; left: -7px; box-shadow: -1px 1px 0 0 var(--line-2); }
.strippop.edge-top::after,
.strippop.edge-bottom::after {
  right: auto; left: var(--caret-x, 50%); margin-top: 0; margin-left: -7px;
}
.strippop.edge-top::after { top: -7px; box-shadow: -1px -1px 0 0 var(--line-2); }
.strippop.edge-bottom::after { top: auto; bottom: -7px; box-shadow: 1px 1px 0 0 var(--line-2); }

/* A quick-select swatch. Round, so it reads as ink rather than as a
   button, and outlined when it is the colour in use - a fill change is
   invisible on the swatch that IS that colour. */
.inkswatch {
  width: 30px; height: 30px; border-radius: 50%; padding: 0; cursor: pointer;
  border: 2px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .22);
}
.inkswatch[aria-pressed="true"] {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .22), 0 0 0 2px var(--bg);
}
.inkswatch.quick { width: 26px; height: 26px; }

.weightbtn {
  width: 34px; height: 30px; border-radius: var(--r-sm); cursor: pointer;
  border: 1.5px solid var(--line); background: var(--bg);
  display: inline-flex; align-items: center; justify-content: center; padding: 0;
}
.weightbtn[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-lo); }
.weightbtn span { display: block; border-radius: 50%; background: var(--ink-2); }
.weightbtn.quick { width: 26px; height: 26px; border: 0; background: none; }

/* The colour and weight flyouts. Positioned in JS from the button that
   owns them - see positionPop() in portal-annotate.js for why. */
.dockpop.inkpop, .dockpop.width {
  display: flex; flex-direction: column; gap: 8px;
  max-height: 70%; overflow-y: auto; min-width: 190px;
}
.pophead { font-size: var(--fs-note); font-weight: 700; }
.pophint { font-size: var(--fs-micro); color: var(--ink-2); }
.inkgrid { display: grid; grid-template-columns: repeat(5, 30px); gap: 8px; }
.weightrow { display: flex; gap: 8px; }

/* The native colour input, dressed as a swatch. The OS picker behind it
   has the full spectrum, an eyedropper and hex entry, all offline - which
   is why there is no hand-built wheel here. */
.inkadd {
  width: 34px; height: 34px; border-radius: 50%; flex: none; cursor: pointer;
  border: 1.5px dashed var(--line); background: var(--bg);
  color: var(--ink-2); padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
}
.inkadd .ic { width: 18px; height: 18px; stroke-width: 2; }

/* Typing a label. Anchored low so the keyboard does not cover it, using
   the same --kb inset the comment sheet uses. */
.antext {
  position: absolute; left: 12px; right: 12px; z-index: 6;
  bottom: calc(var(--kb, 0px) + 12px);
  background: var(--bg); border: 1px solid var(--line); border-radius: var(--r-md);
  box-shadow: 0 3px 14px rgba(0, 0, 0, .3); padding: 10px 12px;
  max-width: 460px; margin: 0 auto;
}
.antext textarea {
  width: 100%; font: inherit; font-size: var(--fs-lead); padding: 8px;
  border: 1.5px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg); color: var(--ink); resize: vertical;
}

/* The photo's edit menu, docked (.dockmenu). Ours, not the platform's -
   see the note in index.html. */
.anmenu .propact:disabled { opacity: .4; cursor: default; }

/* Pan is the navigate-and-select tool, so it takes a grab cursor - and
   never a drawing one, because it never draws. */
.anstage.panning { cursor: grab; }
.anstage.panning:active { cursor: grabbing; }
.anstage.lassoing { cursor: crosshair; }
.dockbtn.danger { color: var(--bad); }

/* The shapes menu.

   SCOPED TO .shaperow, not to every button in the flyout. The rule used
   to be `.dockpop.shapes button`, which also caught the colour swatches
   and weight buttons added below it - forcing them to 38px squares with
   the wrong radius and, worse, out-specificity-ing the rules that make
   them behave, so the whole flyout stopped responding properly.

   The icons must also fit INSIDE their buttons: `.ic` is sized by
   whatever rule last touched it, and unconstrained it drew at full size
   and spilled out of the selection box. */
/* Column, not row: the shape flyout is a stack of rows. This was `display:
   flex; gap: 8px` here and `flex-direction: column` eighty lines below,
   which is a contradiction a reader has to find twice to understand. */
.dockpop.shapes { display: flex; flex-direction: column; align-items: stretch; gap: 8px; }
.dockpop.shapes .shaperow button {
  width: 38px; height: 38px; border-radius: var(--r-sm); flex: none;
  border: 1.5px solid var(--line); background: var(--bg);
  color: var(--ink); display: inline-flex;
  align-items: center; justify-content: center; cursor: pointer;
  padding: 0; overflow: hidden;
}
.dockpop.shapes .shaperow button .ic { width: 20px; height: 20px; flex: none; }
/* ...and the same for the dock button that mirrors the chosen shape. */
/* `flex: none` is the point of this rule - an icon inside a flex button
   would otherwise be shrunk by a longer sibling. The SIZE follows the
   base .dockbtn rule rather than restating it, which is what stopped the
   annotator's palette staying at 22px when the shared dock went to 19. */
.andock .dockbtn .ic { flex: none; }

/* The pen and highlighter ARE the colour they draw with: the body of the
   icon is filled with the ink and outlined in the text colour, so neither
   needs a swatch or a bar. Selected, the button takes a light tint and an
   accent ring instead of the solid fill, which would hide the ink. */
.dockbtn.inked { position: relative; }
.dockbtn.inked .ic {
  width: 21px; height: 21px;
  fill: var(--tool-ink, #d21f1f); stroke: var(--ink); stroke-width: 1.5;
}
.dockbtn.inked[aria-pressed="true"] {
  background: var(--accent-lo); color: var(--ink);
  box-shadow: inset 0 0 0 1.5px var(--accent);
}

/* THE LIVE TEXT EDITOR.

   A real textarea sitting on the photo, inside #an-pan - so the zoom and
   pan transform carries it with the image and the caret cannot drift away
   from where the words will land. Transparent, borderless and in the ink
   colour, so typing looks like the label rather than like a form.

   It is the one thing in the annotator that IS selectable text: the
   overlay rules above make everything else unselectable, and this needs
   a caret. */
/* CONTENT-BOX, against the global border-box reset. The width set on this
   textarea IS the label's box, and the wrapped SVG that replaces it on
   commit is laid out to exactly that width. Under border-box the 1.5px
   border would eat into the content, so the textarea would wrap a hair
   earlier than the finished label - the two would disagree on the last
   word of a line, which is precisely the mismatch this pairing exists to
   avoid. */
.an-liveedit {
  position: absolute; z-index: 3; margin: 0; padding: 0;
  box-sizing: content-box;
  background: rgba(255, 255, 255, .72);
  border: 1.5px dashed #4da3ff; border-radius: var(--r-xs);
  font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
  font-weight: 400; line-height: 1.25;
  resize: none; overflow: hidden;
  -webkit-user-select: text; user-select: text;
  -webkit-touch-callout: default;
}
.an-liveedit:focus { outline: none; border-style: solid; }

/* The resize grip on a label being edited. A sibling of the textarea, in
   the same zoom wrapper, so the two move together. Sized in screen pixels
   through a counter-scale would be nicer still, but a fixed fit-space size
   is close enough at the zooms anyone edits text at. */
.an-editgrip {
  position: absolute; z-index: 4;
  width: 18px; height: 18px; margin: -9px 0 0 -9px;
  border-radius: 50%; background: var(--bg);
  border: 2px solid #4da3ff; cursor: nwse-resize;
  touch-action: none;
}
.an-editgrip::after {
  content: ''; position: absolute; inset: -12px;   /* a real tap target */
}

/* The system picker is opened programmatically from a swatch, so the
   input itself is never seen. */
.hiddenpicker {
  position: absolute; width: 1px; height: 1px; opacity: 0;
  pointer-events: none; border: 0; padding: 0;
}

/* The pencil on the selected swatch: says "tap again to change this one"
   without a line of help text under every flyout. */
.inkswatch.editable::after,
.weightbtn.editable::after {
  content: '✎'; position: absolute; right: -2px; bottom: -4px;
  font-size: var(--fs-cap); line-height: 1; color: var(--ink);
  background: var(--bg); border-radius: 50%; padding: 1px 2px;
  box-shadow: 0 0 0 1px var(--line);
}
.inkswatch, .weightbtn { position: relative; }

.dockpop.shapes .shaperow { display: flex; gap: 6px; }
#an-shapes-settings { display: flex; flex-direction: column; gap: 8px; }

.antext-size { flex: 1; display: flex; align-items: center; gap: 8px; font-size: var(--fs-small); }
.antext-size input { flex: 1; }
.dockpop.shapes .shaperow button[aria-pressed="true"] {
  border-color: var(--accent); color: var(--accent); background: var(--accent-lo);
}
.anstage.shaping { cursor: crosshair; }

/* Markup shown over a thumbnail, so a photo marked up before the comment
   was saved still looks marked up. */
.photothumb .photoink {
  position: absolute; inset: 0; width: 100%; height: 100%;
  pointer-events: none;
}

/* The frame colour picker and the way out, shown only while a snapshot is
   being taken. It FLOATS over the bottom of the sheet rather than taking a
   row of the column: appearing and disappearing in the layout resized the
   stage in the middle of framing a shot, which moved the very thing being
   framed. Positioned against the panel, so it clears the thumbnail rail. */
.snapbar {
  position: absolute; z-index: 6;
  left: 50%; transform: translateX(-50%);
  /* CLEAR OF THE BOTTOM BAR, which is a real row at the foot of the panel
     - and this pill is positioned against the panel, so it has to step
     over it. Lifted by the bar's own two constants, so the two cannot
     overlap by construction rather than by two offsets happening to
     agree. */
  /* Plain `var(--safe-b)` - see the note on .vwbottom's padding. */
  bottom: calc(var(--vwbar-clear, calc(var(--vwbartotal) + var(--safe-b))) + 12px);
  display: flex; align-items: center; gap: 10px;
  padding: 8px 12px; border-radius: 999px;
  background: rgba(17, 24, 39, .82); color: #fff;
  box-shadow: 0 2px 10px rgba(0,0,0,.4);
}
/* The ghost button is drawn for a light card; on this pill it would be
   dark ink on a dark background. */
.snapbar .btn.mini {
  flex: none; color: #fff;
  background: rgba(255,255,255,.16); border-color: rgba(255,255,255,.34);
}
.snapbar-label { font-size: var(--fs-small); font-weight: 600; letter-spacing: .02em; }
/* `.snapcolours` is gone: a snapshot frame is coloured by its visit, so
   there is nothing here to pick. See the note in index.html above
   `.snapbar`, and drawSnapshotRegion in portal-viewer.js. */

/* The snapshot rubber band. In the unscaled pin layer, so it stays a
   crisp one-pixel edge at any zoom instead of being scaled with the
   sheet. Takes THIS WALK'S colour, so what you drag is already the colour
   the frame will be. */
.vwband {
  position: absolute; top: 0; left: 0;
  border: 2px solid var(--band, var(--accent));
  background: rgba(77, 163, 255, .16);
  pointer-events: none; box-sizing: border-box;
}
.vwstage.snapping { cursor: crosshair; }

/* The Chain toggle, a bubble at the top left of the sheet while Measure
   is the tool. Above the ink (3) and the pins. */
.vwchain {
  position: absolute; z-index: 6; top: 10px; left: 10px;
  display: inline-flex; align-items: center; gap: 8px;
  min-height: 40px; padding: 0 14px 0 10px; border-radius: 20px;
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--line); box-shadow: 0 3px 14px rgba(0,0,0,.28);
  font-size: var(--fs-note); font-weight: 600; cursor: pointer;
  touch-action: manipulation; -webkit-user-select: none; user-select: none;
}
.vwchain input { width: 20px; height: 20px; margin: 0; accent-color: var(--accent); cursor: pointer; }
.vwchain:has(input:checked) { border-color: var(--accent); background: var(--accent-lo); }

/* THE MOUSE CURSOR ON A SHEET, desktop only. `data-cursor` comes from
   C.sheetCursor. Each rule names `:active` too, or `.vwstage:active`
   turns every drawing tool into a grabbing hand while the button is
   down. Placed after .erasing and .snapping so it wins at equal
   specificity. Image cursors carry a keyword fallback. */
@media (hover: hover) and (pointer: fine) {
  /* Navigate shows the system Move cursor rather than grab/grabbing:
     Windows has no hand cursors of its own, and Chrome draws those two
     as small bitmaps that do not scale with display DPI. Every other
     drag-to-move control follows suit. */
  .vwstage[data-cursor="grab"], .vwstage[data-cursor="grab"]:active { cursor: move; }
  .vwhandle, .vwhandle:active, .vwpin.lifted, #vw-dock.moving, .tbx-grip,
  .anstage.panning, .anstage.panning:active { cursor: move; }
  /* A corner handle resizes along its diagonal; data-resize comes from
     C.cornerResizeAxis, so it follows the sheet's rotation. */
  .vwhandle[data-resize="nwse"], .vwhandle[data-resize="nwse"]:active { cursor: nwse-resize; }
  .vwhandle[data-resize="nesw"], .vwhandle[data-resize="nesw"]:active { cursor: nesw-resize; }
  .vwhandle[data-resize="ew"], .vwhandle[data-resize="ew"]:active { cursor: ew-resize; }
  .vwstage[data-cursor="pointer"], .vwstage[data-cursor="pointer"]:active { cursor: pointer; }
  .vwstage[data-cursor="move"], .vwstage[data-cursor="move"]:active { cursor: move; }
  .vwstage[data-cursor="crosshair"], .vwstage[data-cursor="crosshair"]:active { cursor: crosshair; }
  .vwstage[data-cursor="text"], .vwstage[data-cursor="text"]:active { cursor: text; }
  .vwstage[data-cursor="copy"], .vwstage[data-cursor="copy"]:active { cursor: copy; }
  .vwstage[data-cursor="pen"], .vwstage[data-cursor="pen"]:active {
    cursor: url("icons/cursors/pen.svg") 3 29, crosshair;
  }
  .vwstage[data-cursor="highlight"], .vwstage[data-cursor="highlight"]:active {
    cursor: url("icons/cursors/highlight.svg") 4 28, crosshair;
  }
  .vwstage[data-cursor="eraser"], .vwstage[data-cursor="eraser"]:active {
    cursor: url("icons/cursors/eraser.svg") 16 16, cell;
  }
  .vwstage[data-cursor="comment"], .vwstage[data-cursor="comment"]:active {
    cursor: url("icons/cursors/comment.svg") 3 29, copy;
  }
  .vwscale select { cursor: pointer; }
}

/* Badges on a thumbnail: markup present, and came off the drawing rather
   than the camera. Without these the only way to find out is to open
   every photo. */
.photomark, .photosnap {
  position: absolute; font-size: var(--fs-micro); font-weight: 700; line-height: 1;
  padding: 2px 4px; border-radius: var(--r-xs);
  background: rgba(0, 0, 0, .66); color: #fff;
}
.photomark { top: 3px; right: 3px; padding: 3px; display: flex; }
.photomark .ic { width: 11px; height: 11px; stroke-width: 2.2; }
.photosnap { bottom: 3px; left: 3px; letter-spacing: .02em; }
button.photothumb { padding: 0; cursor: pointer; }
button.photothumb:active { border-color: var(--accent); }

/* Markup over the full-size photo. Absolute over the <img>, matching its
   box, so the two scale together on any screen. */
.photofull .photoink {
  position: absolute; inset: 0; width: 100%; height: 100%;
  pointer-events: none;
}

/* Fourth-level headings inside the Basis prose. It ran out of levels once
   the electrical basis grew sub-topics. */
.panel.basis h4 {
  font-size: var(--fs-body); margin: 16px 0 4px; color: var(--ink);
}

/* The annotator still has a footer, and still puts its message in it. The
   SHEET VIEWER no longer does - see .vwstage .vwmsg below. */
.vwfoot {
  display: flex; align-items: center; gap: 8px; flex: none;
  padding: 8px 10px; padding-bottom: max(8px, env(safe-area-inset-bottom));
  background: var(--bg-2); border-top: 1px solid var(--line);
}
.vwmsg { flex: 1; min-width: 0; font-size: var(--fs-note); color: var(--ink-2); }
.vwmsg.note { margin: 0; }

/* THE SHEET VIEWER'S STATUS TOAST. It used to be the middle of a bottom
   bar that existed whether it had anything to say or not; now it floats
   over the sheet and `:empty` takes it off screen entirely, which is its
   state nearly all of the time. pointer-events: none because it sits over
   the drawing and must never eat a pan.

   Scoped to `.vwstage` deliberately: the annotator's own message shares
   the class and is a row in a real footer, so an unscoped rule here would
   lift it out of that bar and drop it over the photograph.

   Anything that writes here has to KEEP the `vwmsg` class - inside the
   stage it is what positions the toast, not just what colours it.
   Assigning className wholesale (msg.className = 'note bad') would take
   the toast off its own anchor. */
.vwstage .vwmsg {
  position: absolute; z-index: 3; pointer-events: none;
  /* TOP centre, not bottom: the bottom of the stage already carries the
     comment count on one side and the snapshot controls across it, and a
     message that lands on top of either is a message nobody reads. */
  left: 50%; transform: translateX(-50%); top: 10px;
  flex: none; max-width: min(84%, 420px);
  padding: 7px 12px; border-radius: 999px;
  background: rgba(17,24,39,.78); color: #fff;
  font-size: var(--fs-note); line-height: 1.35; text-align: center;
  box-shadow: 0 2px 8px rgba(0,0,0,.35);
}
.vwstage .vwmsg:empty { display: none; }

/* The measuring readout beside the mouse, placed by transform from
   paintMeasureReadout. Same dark chip as the toast, so it reads over any
   drawing in either scheme. */
.vwreadout {
  position: absolute; z-index: 4; top: 0; left: 0; pointer-events: none;
  display: flex; flex-direction: column; gap: 1px;
  padding: 4px 8px; border-radius: var(--r-sm);
  background: rgba(17,24,39,.85); color: #fff;
  font-size: var(--fs-small); line-height: 1.3; white-space: nowrap;
  box-shadow: 0 2px 8px rgba(0,0,0,.35);
}
.vwreadout[hidden] { display: none; }
.vwreadout-len { font-weight: 700; font-variant-numeric: tabular-nums; }
.vwreadout-len:empty { display: none; }
.vwreadout-scale { opacity: .8; }
.vwstage .vwmsg.bad { background: rgba(153,27,27,.88); }

/* Sheet index rows become tappable, since they now open the sheet. */
.sh-open {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 11px 14px; border: 0; border-bottom: 1px solid var(--line);
  background: none; color: var(--ink); font: inherit; text-align: left;
  cursor: pointer; min-height: 50px;
}
.sh-open:last-child { border-bottom: 0; }
.sh-open:active { background: var(--accent-lo); }
.sh-open .k { flex: 1; min-width: 0; }

/* The way out of a folder, above the content rather than below it. */

/* A sheet row is a container holding two buttons, never a button inside a
   button - browsers drop the inner one, so the menu would never be tapped. */
.sh-row { border-bottom: 1px solid var(--line); }

/* =====================================================================
   A PLAN SET, COLLAPSED

   One <details> per imported PDF, labelled with the file it came from.
   The marker is drawn by us: the platform triangle differs on every OS and
   sits at a different distance from the text on each, which reads as a
   misaligned row rather than as a control.
   ===================================================================== */
.sh-set { border-bottom: 1px solid var(--line); }
.sh-set:last-child { border-bottom: 0; }
.sh-setsum {
  display: flex; align-items: baseline; gap: 8px;
  min-height: var(--tap);
  padding: 10px 12px 10px 30px;
  cursor: pointer; position: relative;
  font-weight: 600;
  /* The native disclosure triangle goes; ::before draws our own. */
  list-style: none;
  /* The row is a tap target on glass, so it must not select on a long
     press the way a summary otherwise does. */
  -webkit-user-select: none; user-select: none;
}
/* Both marker spellings - Safari still wants the -webkit- one. */
.sh-setsum::-webkit-details-marker { display: none; }
.sh-setsum::marker { content: ''; }
/* The disclosure caret, rotating in place. `transform` on a ::before is
   safe here - it is a glyph, not a raster of the sheet list. */
.sh-setsum::before {
  content: ''; position: absolute; left: 12px; top: 50%;
  width: 7px; height: 7px; margin-top: -5px;
  border-right: 2px solid var(--ink-2);
  border-bottom: 2px solid var(--ink-2);
  transform: rotate(-45deg);
  transition: transform .12s ease;
}
.sh-set[open] > .sh-setsum::before { transform: rotate(45deg); margin-top: -4px; }
.sh-setsum:active { background: var(--accent-lo); }
.sh-set[open] > .sh-setsum { color: var(--accent); }
/* The file name can be long and must ellipse rather than push the count
   off the row. */
.sh-setname {
  flex: 1; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sh-setmeta {
  flex: none; font-size: var(--fs-note); font-weight: 400; color: var(--ink-2);
  font-variant-numeric: tabular-nums;
}
/* Indented so a sheet reads as belonging to the set above it. */
.sh-setrows { padding-left: 10px; border-top: 1px solid var(--line); }
.sh-row:last-child { border-bottom: 0; }
.sh-head { display: flex; align-items: stretch; }
.sh-head .sh-open { border-bottom: 0; flex: 1; min-width: 0; }
.sh-more {
  flex: none; width: 44px; border: 0; background: none; cursor: pointer;
  color: var(--ink-2); display: flex; align-items: center; justify-content: center;
}
.sh-more .ic { width: 20px; height: 20px; }
.sh-more:active { background: var(--accent-lo); }
/* THE SET-LEVEL OVERFLOW, inside the <summary>.

   `align-self: center` because `.sh-setsum` is baseline-aligned so the file
   name and its count sit on one line - a 34px control joining that baseline
   group hangs below the row and drags the summary's height with it.

   The negative right margin pulls the glyph into the summary's own 12px
   padding so it lines up vertically with the per-sheet kebabs in the rows
   underneath, rather than sitting inboard of them and reading as a
   different kind of control. */
.sh-setmore {
  align-self: center;
  width: 40px; height: 34px;
  margin: -4px -8px -4px 0;
  border-radius: var(--r-sm);
}
/* The revision's trashcan, on the far right of the row Edit sits in.
   `margin-left: auto` inside the flex row is what puts it there, so the
   confirming tap in the panel it opens is nowhere near it. Tinted rather
   than filled: it only asks - see buildRevisionMenu. */
/* `.btn.sh-revdel`, NOT `.sh-revdel`. The colour has to out-specify
   `.btn.ghost`, which sets the accent - one class cannot, so the icon
   inherited `currentColor` and drew the trashcan in the app's blue. */
.btn.sh-revdel {
  margin-left: auto;
  color: var(--bad); border-color: var(--bad);
  padding: 0 12px;
}
.sh-menu { padding: 0 14px 12px; }
/* AFTER `.sh-menu`, not before. Equal specificity, so the shorthand above
   would otherwise reset this back to 0 on source order alone and the set
   menu would butt flush against the summary. */
.sh-setmenu { padding-top: 10px; }
.sh-menu .stackbtns { margin-top: 0; }
.sh-menu .renamebox, .sh-menu .confirm { margin-top: 0; }

/* Folder rows reuse the sheet row; the icon marks them as containers. */
.renamebox {
  margin-top: 12px; padding: 12px; border: 1.5px solid var(--line);
  border-radius: var(--r-sm); background: var(--bg-2);
}

/* .pathcopy / .pathtext / .pathdone styled the copyable server path on the
   project screen. The server location was removed - nothing read it - so
   the rules went with it rather than being left to be inherited by
   something that happens to reuse the name. */

/* The annotator's delete confirm, in its footer. The question sits at the
   opposite end of the screen from the button that asked it: a confirm
   under the finger already on its way down is not a confirm. */
.anconfirm {
  display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
  flex-wrap: wrap; font-size: var(--fs-note); color: var(--ink);
}
.anconfirm span { flex: 1; min-width: 120px; }
.anconfirm .btn { flex: none; }

/* =====================================================================
   A PLAIN CENTRED DIALOG

   Same geometry as .phtmodal, deliberately not the same selector: the
   photo modal's rules are asserted by name in the smoke suite, and
   grouping a second class onto them would hide `.phtmodal {` from an
   anchored match. Two short rules is the cheaper of the two prices.

   z-index 55 matches the photo modal - above the app bar and tab bar
   (50), below the About sheet (60).
   ===================================================================== */
.modal {
  position: fixed; inset: 0; z-index: 55;
  display: flex; align-items: center; justify-content: center;
  padding: max(12px, env(safe-area-inset-top, 0px)) 12px
           max(12px, env(safe-area-inset-bottom, 0px));
}
.modalscrim {
  position: absolute; inset: 0;
  background: rgba(0, 0, 0, .55);
}
.modalbox {
  position: relative;             /* over the scrim */
  width: min(460px, 100%);
  /* The box scrolls, not the page behind it: a keyboard up on a phone in
     landscape leaves very little room, and the alternative is a Remove
     button that cannot be reached. */
  max-height: 100%; overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--bad); border-radius: var(--radius);
  box-shadow: 0 8px 30px rgba(0, 0, 0, .38);
  padding: 14px;
}
.modalbox h2 { margin-top: 0; font-family: var(--font-head); font-weight: 600; }
.modalbox .f { margin-top: 10px; }
.modalbox input { text-transform: none; }
/* The box above is bordered in `--bad` because the only modal there was is
   a destructive one. A modal that asks a question rather than destroying
   something must not borrow that colour - red chrome on an import reads as
   a warning nobody can find the cause of. */
.modalbox.plain { border-color: var(--line); }

/* The question box (portal-ask.js). Above the roof drain full-screen
   layer (90), which asks most of its questions. */
.modal.ask { z-index: 95; }
.modal.ask h2 { font-size: var(--fs-title); margin-bottom: 4px; }
.modal.ask .hint { font-size: var(--fs-body); line-height: 1.45; margin-top: 4px; }
.modal.ask .hint[hidden] + .f { margin-top: 12px; }
.askbtns { justify-content: flex-end; margin-top: 16px; }
.askbtns .btn { min-width: 96px; }

/* Every modal arrives rather than appears: the scrim fades and the box
   rises. Transform on the box is safe - it holds no fixed descendants. */
@keyframes scrimin { from { opacity: 0; } }
@keyframes boxin   { from { opacity: 0; transform: translateY(14px) scale(.98); } }
.modal:not([hidden]) .modalscrim { animation: scrimin .18s var(--ease-out); }
.modal:not([hidden]) .modalbox   { animation: boxin .22s var(--ease-out); }
@media (prefers-reduced-motion: reduce) {
  .modal:not([hidden]) .modalscrim, .modal:not([hidden]) .modalbox { animation: none; }
}

/* Pushes a button to the far end of a `.stackbtns` row, so two buttons in
   one row read as two different acts rather than as a pair. */
.pushright { margin-left: auto; }

/* The set list's heading row: the count on the left, Select all pushed to
   the right. The hint keeps its own margins, so the row only has to align
   them and close the gap the paragraph would otherwise leave under it. */
.sh-revsets-head {
  display: flex; align-items: baseline; gap: 10px;
  margin-top: 10px;
}
.sh-revsets-head .hint { flex: 1; min-width: 0; margin: 0; }
.sh-revsets-head .btn { flex: none; }

/* WHICH REVISION AN INDEX ROW OPENS. Beside the sheet number, because it
   qualifies the number rather than the comment counts on the other side of
   the row. Accent-tinted, not `--bad`: a superseded drawing is the normal
   state of a live project, not a problem. It shares the `.flag` box so a
   row carrying both this and "unnamed" lines them up. */
.sh-rev {
  flex: none; margin-left: 6px; padding: 1px 6px;
  border: 1px solid var(--accent); border-radius: 999px;
  background: var(--accent-lo); color: var(--accent);
  font-size: var(--fs-cap); font-weight: 600; letter-spacing: .02em;
  white-space: nowrap;
}
/* The file a revision arrived as. The set's own heading carries the
   revision NAME - what the card is read for - so the file name drops to a
   quiet line above the sheets rather than competing with it. */
.sh-revfile {
  padding: 4px 10px 6px; font-size: var(--fs-small); color: var(--ink-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* =====================================================================
   A REVISION MARKER IN A LIST OF VISITS

   Where a revision was issued between two walks, the list of visits says
   so at the point it happened. A rule with the revision's name over it,
   NOT a row: it is not selectable and must not look like it is.

   The name only. The publish date is what positions the marker and is
   deliberately not drawn - a reader is placing two walks either side of an
   issue, not auditing when it went out. */
.revmark {
  display: flex; align-items: center; gap: 8px;
  margin: 10px 0 6px; font-size: var(--fs-note); font-weight: 600;
  color: var(--ink-2);
}
.revmark::before, .revmark::after {
  content: ''; height: 1px; flex: 1; background: var(--line);
}
/* Inside a comment log the rule spans the list; the leading half would
   otherwise squeeze the name off centre on a phone. */
.revmark.lead::before { flex: 0 0 12px; }

/* Destructive actions read as destructive before they are tapped. */
.danger-card { border-color: var(--bad); }
.btn.danger { background: var(--bad-fill); color: #fff; border: 0; }
.btn.danger:disabled { opacity: .45; }
/* A GHOST THAT IS STILL DESTRUCTIVE. A solid red fill is right for
   removing a project, which takes a typed confirmation and cannot be
   undone on the device. It is too loud for a delete that tombstones one
   tab and syncs like any other edit - a red-filled button beside Rename
   reads as the dangerous one on a screen where nothing is. Declared
   after .btn.danger so its background wins. */
.btn.ghost.danger {
  background: var(--bg); color: var(--bad); border: 1.5px solid var(--line);
}
.btn.ghost.danger:active { background: var(--bad-bg); }
.btn:disabled { opacity: .45; cursor: default; }
/* A button whose action is running: a spinner after the label, and it stays
   readable rather than fading like a disabled one. */
@keyframes btnspin { to { transform: rotate(360deg); } }
.btn[aria-busy="true"] { opacity: .85; }
.btn[aria-busy="true"]::after {
  content: ''; width: 14px; height: 14px; flex: none;
  border: 2px solid currentColor; border-right-color: transparent;
  border-radius: 50%; animation: btnspin .7s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .btn[aria-busy="true"]::after { animation-duration: 2s; }
}
/* The scroll margins are what make `scrollIntoView` land somewhere useful:
   the app bar is sticky at the top and the tab bar sticky at the bottom, so
   without them the panel is revealed underneath one or the other. */
.confirm {
  margin-top: 12px; padding: 12px; border: 1.5px solid var(--bad);
  border-radius: var(--r-sm); background: var(--bad-bg);
  scroll-margin-top: 64px;
  scroll-margin-bottom: calc(var(--tabh) + var(--safe-b) + 12px);
}
.confirm .f { margin-top: 10px; }
.confirm input { text-transform: none; }
/* The question itself. It belongs to the box, not to a note inside it:
   the box already carries the red border, the tint and the padding, so a
   note adds a second background and a 4px accent bar and the confirm
   reads as a panel nested in a panel. */
.confirm-text {
  margin: 0; font-size: var(--fs-body); line-height: 1.4; color: var(--bad);
}
.confirm-text + .confirm-text { margin-top: 8px; }

/* =====================================================================
   HIT AREAS FOR COMPACT CONTROLS

   The header and viewer chrome buttons are drawn at 28-38 px so the bars
   stay slim; an invisible pseudo-element takes each one's touch area to
   roughly 44-48 px without changing the layout. :where() keeps the
   position rule at zero specificity, so any control positioned
   absolutely elsewhere keeps its own position.
   ===================================================================== */
:where(header.appbar .home-icon-btn, header.appbar .backlink,
       .appid .verbtn, .appid .acctbtn,
       .vwbtn, .vwbbtn, .vwqr-btn, .inkadd, .rdchip-pick,
       .updatebar button, .errbar button) { position: relative; }
:where(header.appbar .home-icon-btn, header.appbar .backlink)::before,
:where(.appid .verbtn, .appid .acctbtn)::before,
:where(.updatebar button, .errbar button)::before {
  content: ''; position: absolute; inset: -9px -5px;
}
:where(.vwbtn, .vwbbtn, .inkadd, .rdchip-pick)::before {
  content: ''; position: absolute; inset: -5px -3px;
}
:where(.vwqr-btn)::before { content: ''; position: absolute; inset: -8px -4px; }

/* The home screen's brand band: the firm mark and name. The mark keeps its
   own white faces, so it sits on a white tile in both colour schemes. */
.home-brand {
  display: flex; align-items: center; gap: 14px;
  margin: 4px 2px 16px;
}
.home-mark {
  width: 52px; height: auto; flex: none;
  padding: 6px 7px; box-sizing: content-box;
  background: #ffffff; border-radius: var(--r-md); box-shadow: var(--shadow-1);
}
.home-brandtext { display: flex; flex-direction: column; min-width: 0; }
.home-firm {
  font-family: var(--font-head); font-weight: 900; font-size: var(--fs-title);
  line-height: 1.2; color: var(--ink);
}
/* Dark grey, not the brand warm grey: #827970 on the warm page is about
   3.4:1, short of what 13 px text needs. */
.home-app { font-size: var(--fs-note); letter-spacing: .02em; color: var(--ink-2); }

/* =====================================================================
   THE TEXT BOX ON A SHEET (viewer/markup/text.js)

   A real textarea laid over the box in stage pixels; portal-viewer sets
   its transform, width, height and font size on every view change. The
   font and weight must equal C.TEXT_FONT_STACK / C.TEXT_FONT_WEIGHT, or
   the words wrap differently while typing than once placed.
   ===================================================================== */
.vwtextedit {
  position: absolute; left: 0; top: 0; z-index: 5;
  transform-origin: 0 0;
}
.vwtextedit-input {
  display: block; margin: 0; padding: 0; border: 0; border-radius: 0;
  box-sizing: border-box; resize: none; overflow: hidden;
  font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
  font-weight: 400; letter-spacing: 0;
  white-space: pre-wrap; overflow-wrap: break-word; word-break: break-word;
  background: rgba(255, 255, 255, .82);
  outline: 1.5px dashed #4e5e78; outline-offset: 3px;
  text-shadow: 0 0 2px #fff, 0 0 2px #fff;
  caret-color: currentColor;
  -webkit-user-select: text; user-select: text;
}
.vwtextedit-input::placeholder { color: rgba(0, 0, 0, .38); text-shadow: none; }
.vwtextedit-input:focus { outline-style: solid; box-shadow: none; }

/* Width grip on the right edge, move handle under the bottom-left corner.
   Both are drawn small and hit large. */
.vwtextedit-grip {
  position: absolute; right: -13px; top: 50%; width: 18px; height: 18px;
  margin-top: -9px; border-radius: 50%;
  background: #ffffff; border: 2px solid #4e5e78;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
  cursor: ew-resize; touch-action: none;
}
.vwtextedit-grip::before { content: ''; position: absolute; inset: -14px; }
.vwtextedit-move {
  position: absolute; left: -4px; top: calc(100% + 8px);
  width: 34px; height: 34px; padding: 0; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: #4e5e78; color: #ffffff; border: 2px solid #ffffff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
  cursor: move; touch-action: none;
}
.vwtextedit-move .ic { width: 18px; height: 18px; }
.vwtextedit-move::before { content: ''; position: absolute; inset: -7px; }

/* The bar that rides with the box: size, colour, delete, done. */
.vwtextbar {
  position: absolute; left: 0; top: 0; z-index: 6;
  display: flex; align-items: center; gap: 6px;
  padding: 6px; border-radius: var(--radius);
  background: var(--bg); color: var(--ink);
  box-shadow: var(--shadow-2);
  touch-action: none;
}
.vwtextbar-btn {
  width: 40px; height: 40px; padding: 0; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; border-radius: var(--r-md); background: var(--bg-2); color: var(--ink);
  cursor: pointer;
}
.vwtextbar-btn .ic { width: 20px; height: 20px; }
.vwtextbar-btn:active { background: var(--accent-lo); }
.vwtextbar-btn.danger { color: var(--bad); }
.vwtextbar-swatches { display: flex; gap: 4px; padding: 0 4px; }
.vwtextbar-swatch {
  position: relative; width: 26px; height: 26px; padding: 0; flex: none;
  border-radius: 50%; border: 2px solid var(--bg);
  background: var(--swatch, #000);
  box-shadow: 0 0 0 1px var(--line); cursor: pointer;
}
.vwtextbar-swatch::before { content: ''; position: absolute; inset: -6px -2px; }
.vwtextbar-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--accent); }
.vwtextbar-done { min-height: 40px; padding: 0 14px; }
@media (max-width: 480px) {
  .vwtextbar { gap: 3px; padding: 5px; }
  .vwtextbar-btn { width: 34px; height: 40px; }
  .vwtextbar-swatches { gap: 3px; padding: 0 2px; }
  .vwtextbar-swatch { width: 20px; height: 20px; }
  .vwtextbar-done { padding: 0 10px; }
}

/* =====================================================================
   THE ATTACHED SETTINGS PANEL (sheet viewer and annotator; portal-tools)

   Slides out from BEHIND the dock (z-index under it), shares its height
   and top, and lays its settings out as one vertical column. The flat
   right edge tucks 12 px under the dock's rounded one. portal-tools sets
   right, top, height and max-width; everything else is here.
   ===================================================================== */
.dockpop.attached {
  z-index: 3; width: 136px; min-width: 0; max-height: none;
  padding: 12px 22px 12px 12px;
  border-radius: 14px 0 0 14px; border-right: 0;
  display: flex; flex-direction: column; gap: 12px;
  overflow-y: auto; overflow-x: hidden;
  box-shadow: -6px 4px 22px rgba(0, 0, 0, .22);
  transform-origin: right center;
}
.dockpop.attached .pophead {
  font-family: var(--font-head); font-weight: 600; font-size: var(--fs-note);
  letter-spacing: .01em; color: var(--ink);
  padding-bottom: 8px; border-bottom: 1px solid var(--line-2);
}
.dockpop.attached .inkgrid { grid-template-columns: repeat(2, 34px); gap: 10px; justify-content: start; }
.dockpop.attached .inkswatch { width: 34px; height: 34px; }
.dockpop.attached .weightrow { flex-direction: column; align-items: flex-start; gap: 8px; }
.dockpop.attached .weightbtn { width: 78px; height: 34px; border-radius: var(--r-md); }
.dockpop.attached .shaperow { display: grid; grid-template-columns: repeat(2, 38px); gap: 8px; }
.dockpop.attached [id$="-shapes-settings"] { display: flex; flex-direction: column; gap: 12px; }
.dockpop.attached .antext-size { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-small); }
.dockpop.attached .antext-size input[type="range"] { width: 100%; }
.dockpop.attached .btn.mini { white-space: normal; line-height: 1.2; min-height: 40px; }
.dockpop.attached > * { flex: none; }
.dockpop.attached .antext-size { flex: none; justify-content: flex-start; align-items: stretch; margin: 0; }
.dockpop.attached input[type="range"] { accent-color: var(--accent); }
/* The hint sits at the foot of the panel, the same place for every tool. */
.dockpop.attached .pophint { line-height: 1.35; margin-top: auto; padding-top: 8px; }

/* Out from behind the dock on open; a quick cross-fade when the tool
   changes under it. */
@keyframes panelgrow {
  from { transform: translateX(calc(100% - 24px)); opacity: .2; }
  to   { transform: none; opacity: 1; }
}
@keyframes panelswap {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: none; }
}
.dockpop.attached.grow { animation: panelgrow .26s var(--ease-out); }
@keyframes panelshrink {
  from { transform: none; opacity: 1; }
  to   { transform: translateX(calc(100% - 12px)); opacity: 0; }
}
.dockpop.attached.shrink { animation: panelshrink .22s cubic-bezier(.4, 0, .8, .4) forwards; pointer-events: none; }
.dockpop.attached.swap > * { animation: panelswap .18s var(--ease-out); }
@media (prefers-reduced-motion: reduce) {
  .dockpop.attached.grow, .dockpop.attached.swap > *, .dockpop.attached.shrink { animation: none; }
}

/* =====================================================================
   THE STRIP (sheet viewer): the attached panel as one column the dock's
   own width - Notability's second toolbar row, turned on its side. The
   12 px on the right sits under the dock.
   ===================================================================== */
.dockpop.attached.strip {
  width: auto; padding: 10px 16px 10px 4px; gap: 8px;
  align-items: center;
  scrollbar-width: none;
}
.dockpop.attached.strip::-webkit-scrollbar { display: none; }
.stripdiv { width: 24px; height: 1px; background: var(--line); flex: none; margin: 2px 0; }

.stripswatch, .stripadd, .stripweight, .stripshape, .stripstep {
  position: relative; flex: none; width: 32px; height: 32px; padding: 0;
  border-radius: 50%; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
.stripswatch {
  border: 0; background: var(--swatch, #000);
  box-shadow: inset 0 0 0 1px var(--line);
  transition: transform .12s var(--ease-out);
}
.stripswatch[aria-pressed="true"] {
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--swatch, #000);
}
.stripswatch:active { transform: scale(.92); }
.stripswatch-pen { width: 15px; height: 15px; color: #fff; stroke-width: 2.2;
  filter: drop-shadow(0 0 1px rgba(0, 0, 0, .5)); }
.stripadd { border: 0; background: none; color: var(--ink-2); }
.stripadd .ic { width: 22px; height: 22px; stroke-width: 1.8; }
.stripweight { border: 0; background: var(--bg-2); }
.stripweight span { display: block; border-radius: 50%; }
.stripweight[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--accent); background: var(--accent-lo); }
.stripshape { border-radius: var(--r-sm); border: 0; background: none; color: var(--ink); }
.stripshape .ic { width: 20px; height: 20px; }
.stripshape[aria-pressed="true"] { background: var(--accent-lo); color: var(--accent); }
.stripstep { border: 0; background: var(--bg-2); color: var(--ink); }
.stripstep .ic { width: 16px; height: 16px; stroke-width: 2; }
.striptextsample {
  flex: none; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  font-weight: 700; line-height: 1; min-height: 26px;
  display: flex; align-items: center; justify-content: center;
}
.strippreview { width: 32px; height: 32px; flex: none; }
.stripmode {
  flex: none; width: 36px; min-height: 30px; padding: 2px; border-radius: var(--r-sm);
  border: 1px solid var(--line); background: var(--bg); color: var(--ink);
  font: inherit; font-size: var(--fs-micro); font-weight: 700; cursor: pointer;
}
.stripmode[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-lo); }

/* The Colours palette and the weight editor, beside the strip, with a
   caret at the control that opened them (--caret-y, set by the tool). */
.strippop {
  position: absolute; z-index: 7;
  width: min(328px, calc(100vw - 110px));
  padding: 16px 16px 12px; border-radius: 18px;
  background: var(--bg); color: var(--ink);
  box-shadow: var(--shadow-2), 0 0 0 1px var(--line-2);
  animation: strippopin .18s var(--ease-out);
}
.strippop::after {
  content: ''; position: absolute; right: -7px; top: var(--caret-y, 50%);
  width: 14px; height: 14px; margin-top: -7px;
  background: var(--bg); transform: rotate(45deg);
  box-shadow: 1px -1px 0 0 var(--line-2);
}
@keyframes strippopin { from { opacity: 0; transform: translateX(8px) scale(.98); } }
@media (prefers-reduced-motion: reduce) { .strippop { animation: none; } }
.strippop-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.strippop-head strong { font-family: var(--font-head); font-weight: 600; font-size: var(--fs-title); }
.strippop-tools { display: flex; align-items: center; gap: 10px; }
.strippop-delete {
  width: 30px; height: 30px; border-radius: 50%; padding: 0; border: 0; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--bad-bg); color: var(--bad);
}
.strippop-delete .ic { width: 16px; height: 16px; }
.strippop-delete::before, .strippop-custom::before { content: ''; position: absolute; inset: -7px; }
.strippop-delete, .strippop-custom { position: relative; }
.strippop-custom {
  width: 30px; height: 30px; border-radius: 50%; padding: 0; border: 0; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center; color: #fff;
  background: conic-gradient(#e9c54a, #e0683d, #d21f1f, #8a55c9, #0069c0, #5bc0d6, #1b7a3c, #e9c54a);
}
.strippop-custom .ic { width: 16px; height: 16px; stroke-width: 2.6;
  filter: drop-shadow(0 0 1px rgba(0, 0, 0, .6)); }
.strippop-grid {
  display: grid; grid-template-columns: repeat(8, 1fr); gap: 10px 8px;
  touch-action: pan-y;
}
.strippop-swatch {
  width: 100%; aspect-ratio: 1; max-width: 32px; justify-self: center;
  border-radius: 50%; border: 0; padding: 0; cursor: pointer;
  background: var(--swatch, #000); box-shadow: inset 0 0 0 1px var(--line);
}
.strippop-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--swatch, #000); }
.strippop-dots { display: flex; justify-content: center; gap: 8px; margin-top: 14px; }
.strippop-dot {
  width: 8px; height: 8px; padding: 0; border: 0; border-radius: 50%;
  background: var(--line); cursor: pointer; position: relative;
}
.strippop-dot::before { content: ''; position: absolute; inset: -10px; }
.strippop-dot[aria-pressed="true"] { background: var(--ink); }
.strippop .widthpreview { display: flex; align-items: center; justify-content: center; min-height: 36px; }
.strippop input[type="range"] { width: 100%; accent-color: var(--accent); }
@media (max-width: 480px) {
  .strippop-grid { grid-template-columns: repeat(4, 1fr); }
}

/* The strip's contents sit in the middle of its height, and scroll from
   the top when they do not fit (auto margins, not justify-content, which
   would push the first item out of reach). */
.dockpop.attached.strip > :first-child { margin-top: auto; }
.dockpop.attached.strip > :last-child { margin-bottom: auto; }

/* The lasso's settings: a two-button segmented control for the shape, and
   one toggle for enclose/touch - oblong, so an icon and a word fit. */
.stripsegs {
  display: flex; flex-direction: column; gap: 3px; padding: 3px; flex: none;
  border-radius: var(--r-md); background: var(--bg-2);
}
.stripseg {
  width: 34px; min-height: 40px; padding: 4px 0; flex: none;
  border: 0; border-radius: var(--r-sm); background: none; color: var(--ink-2); cursor: pointer;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
  font: inherit; font-size: var(--fs-tiny); font-weight: 700; letter-spacing: .01em;
  transition: background-color .15s var(--ease-out), color .15s var(--ease-out);
}
.stripseg .ic { width: 20px; height: 20px; }
.stripseg[aria-pressed="true"] { background: var(--bg); color: var(--accent); box-shadow: var(--shadow-1); }
/* SIDEWAYS: a tall pill the length of the word, its icon-and-word row
   turned a quarter so it reads bottom to top. The row is sized as if it
   were horizontal and rotated about its centre, so the pill's own box is
   what the layout sees. */
.stripsegtoggle {
  position: relative; width: 34px; height: 112px; min-height: 0; padding: 0;
  border-radius: 17px; background: var(--bg-2);
}
.stripsegrot {
  position: absolute; left: 50%; top: 50%; width: 112px; height: 34px;
  transform: translate(-50%, -50%) rotate(-90deg);
  display: flex; align-items: center; justify-content: center; gap: 6px;
  font-size: var(--fs-small); font-weight: 700; letter-spacing: .02em; white-space: nowrap;
}
.stripsegtoggle .encompass-ic { width: 27px; height: 17px; flex: none; }
/* The eraser's "Stroke" is a shorter word, so a shorter pill. */
.stripsegtoggle.stripsegshort { height: 86px; }
.stripsegshort .stripsegrot { width: 86px; }
.stripsegtoggle[aria-pressed="true"] { background: var(--accent-lo); color: var(--accent); box-shadow: inset 0 0 0 1.5px var(--accent); }

/* =====================================================================
   THE TOOLBOX SETTINGS (portal-tools openToolboxSettings)

   Built on the pattern of a grouped settings sheet: a title bar with
   Cancel and Done, a line of explanation, then the Toolbox list and the
   Hidden Tools list as rounded groups of rows. Appended to <body>, so it
   is outside every panel's containing block.
   ===================================================================== */
.tbx { position: fixed; inset: 0; z-index: 97; display: flex; align-items: center; justify-content: center;
  padding: max(16px, env(safe-area-inset-top, 0px)) 16px max(16px, env(safe-area-inset-bottom, 0px)); }
.tbx-scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, .38); opacity: 0; transition: opacity .2s var(--ease-out); }
.tbx-card {
  position: relative; width: min(620px, 100%); max-height: 100%;
  display: flex; flex-direction: column; overflow: hidden;
  border-radius: 20px; background: var(--bg-2); color: var(--ink);
  box-shadow: 0 18px 60px rgba(0, 0, 0, .35);
  transform: translateY(18px) scale(.98); opacity: 0;
  transition: transform .24s var(--ease-out), opacity .2s var(--ease-out);
}
.tbx.open .tbx-scrim { opacity: 1; }
.tbx.open .tbx-card { transform: none; opacity: 1; }
.tbx.closing .tbx-scrim, .tbx.closing .tbx-card { opacity: 0; }
@media (prefers-reduced-motion: reduce) { .tbx-card, .tbx-scrim { transition: none; } }
.tbx-head {
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  padding: 18px 20px 6px;
}
.tbx-head h2 { margin: 0; font-family: var(--font-head); font-weight: 600; font-size: var(--fs-title); text-align: center; }
.tbx-link {
  justify-self: start; border: 0; background: none; padding: 8px 4px; min-height: 40px;
  font: inherit; font-size: var(--fs-lead); font-weight: 600; color: var(--accent); cursor: pointer;
}
.tbx-done { justify-self: end; font-weight: 700; }
.tbx-body { overflow-y: auto; padding: 0 20px 22px; -webkit-overflow-scrolling: touch; }
.tbx-desc { margin: 10px 8px 18px; font-size: var(--fs-body); line-height: 1.45; color: var(--ink-2); }
.tbx-label { margin: 0 8px 7px; font-size: var(--fs-note); font-weight: 600; color: var(--ink-2); }
.tbx-list {
  border-radius: var(--radius); background: var(--bg); overflow: hidden; margin-bottom: 26px;
  box-shadow: var(--shadow-1);
}
.tbx-row {
  position: relative; display: flex; align-items: center; gap: 14px;
  min-height: 58px; padding: 0 12px 0 14px; background: var(--bg);
  transition: transform .16s var(--ease-out);
}
.tbx-row + .tbx-row::before {
  content: ''; position: absolute; top: 0; left: 108px; right: 0; height: 1px; background: var(--line-2);
}
.tbx-row.dragging { z-index: 2; transition: none; box-shadow: 0 8px 22px rgba(0, 0, 0, .18); }
.tbx-btn {
  width: 40px; height: 40px; flex: none; padding: 0; border: 0; border-radius: var(--r-md);
  background: none; color: var(--ink-2); cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
.tbx-btn .ic { width: 24px; height: 24px; }
.tbx-btn:active { background: var(--accent-lo); }
.tbx-dup { margin-left: auto; }
.tbx-icon { width: 30px; height: 30px; flex: none; }
.tbx-icon.inked { fill: var(--tool-ink, #d21f1f); stroke: var(--ink); stroke-width: 1.5; }
.tbx-name { font-size: var(--fs-title); color: var(--ink); flex: 1; min-width: 0; }
/* A tool the open surface lacks: kept in the shared list, dimmed. */
.tbx-off .tbx-icon, .tbx-off .tbx-name { opacity: .5; }
.tbx-where { display: block; font-size: var(--fs-small); color: var(--ink-2); }
.tbx-dup + .tbx-grip, .tbx-name + .tbx-grip { margin-left: 0; }
.tbx-grip {
  width: 44px; height: 48px; flex: none; margin-left: auto; color: var(--ink-2);
  display: flex; align-items: center; justify-content: center; cursor: grab; touch-action: none;
}
.tbx-grip .ic { width: 24px; height: 24px; }
.tbx-seprow { min-height: 30px; padding-left: 14px; }
.tbx-sepbar { flex: 1; height: 14px; border-radius: var(--r-xs); background: var(--accent-lo); }
.tbx-hiddenrow .tbx-icon, .tbx-hiddenrow .tbx-name { opacity: .5; }
.tbx-hiddenrow .tbx-lead { color: var(--ink-2); opacity: .7; }
.tbx-none { color: var(--ink-2); font-size: var(--fs-body); justify-content: center; }

/* THE SEVERITY MENU, beside Done in both comment editors: a pill with a
   dot in the chosen severity's colour (--sev, set by portal-ui), and a
   native select for the choice itself. */
.vwc-sev {
  position: relative; display: inline-flex; align-items: center; gap: 7px; flex: none;
  min-height: 38px; padding: 0 30px 0 11px; border-radius: 19px;
  background: var(--bg-2); color: var(--ink); cursor: pointer;
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--sev, var(--line)) 55%, transparent);
}
.vwc-sev-dot {
  width: 11px; height: 11px; border-radius: 50%; flex: none;
  background: var(--sev, #4e5e78);
}
.vwc-sev select {
  width: auto; min-height: 36px; padding: 0; border: 0; box-shadow: none;
  background: transparent; color: inherit; font: inherit; font-size: var(--fs-body); font-weight: 600;
  -webkit-appearance: none; appearance: none; cursor: pointer;
}
.vwc-sev select:focus { outline: none; box-shadow: none; }
.vwc-sev::after {
  content: ''; position: absolute; right: 13px; top: 50%;
  width: 6px; height: 6px; margin-top: -5px;
  border-right: 2px solid var(--ink-2); border-bottom: 2px solid var(--ink-2);
  transform: rotate(45deg); pointer-events: none;
}
.vwc-sev:focus-within { box-shadow: inset 0 0 0 2px var(--accent); }

/* =====================================================================
   The in-app camera

   Full screen over everything but the question box. The video fills the
   screen and is cropped rather than letterboxed; the saved frame is the
   whole stream, so a little more than the viewfinder shows is kept.
   ===================================================================== */
.cam {
  position: fixed; inset: 0; z-index: 92;
  background: var(--cam-bg); color: var(--cam-ink);
  display: flex; flex-direction: column;
  touch-action: none;
}
.cam-view {
  position: relative; flex: 1 1 auto; min-height: 0;
  overflow: hidden; background: var(--cam-bg);
}
.cam-video {
  width: 100%; height: 100%; display: block;
  object-fit: cover; background: var(--cam-bg);
  transform: scale(var(--cam-crop, 1)); transform-origin: 50% 50%;
}
/* The zoom wheel, down the right edge. Narrow, faded at both ends, and
   dimmed to the readout alone while idle. Ticks sit on a strip that is
   translated as a whole, so a zoom change moves one element. */
.cam-wheel {
  /* Low on the right, where a right thumb holding the phone rests. */
  position: absolute; right: 4px; bottom: 12px; height: 40%; width: 56px;
  overflow: hidden; touch-action: none; cursor: ns-resize;
  -webkit-mask-image: linear-gradient(transparent, #000 25%, #000 75%, transparent);
  mask-image: linear-gradient(transparent, #000 25%, #000 75%, transparent);
}
.cam-wheel-ticks {
  position: absolute; left: 0; right: 0; top: 50%; height: 0;
  transition: opacity 400ms ease;
}
.cam-tick {
  position: absolute; right: 0; top: 0; height: 0;
  display: flex; align-items: center; justify-content: flex-end; gap: 4px;
  color: var(--cam-ink);
  font: 600 var(--fs-micro) / 1 var(--font); font-variant-numeric: tabular-nums;
  /* Readable over a bright scene as well as a dark one. */
  text-shadow: 0 0 3px rgba(0, 0, 0, .85);
}
.cam-tick::after {
  content: ''; display: block; width: 8px; height: 1px; background: currentColor;
  box-shadow: 0 0 2px rgba(0, 0, 0, .85);
}
.cam-tick.major::after { width: 14px; height: 2px; }
.cam-wheel.idle .cam-wheel-ticks { opacity: .3; }
.cam-tick.near { color: transparent; text-shadow: none; }
.cam-tick.near::after { background: var(--cam-ink); }
/* The current zoom: the wheel's own figure, larger, on the centre line.
   Not dimmed when the wheel is idle. Clicks pass through to the wheel. */
.cam-wheel-now {
  position: absolute; right: 18px; top: 50%; transform: translateY(-50%);
  color: var(--cam-ink); pointer-events: none;
  font: 700 var(--fs-lead) / 1 var(--font); font-variant-numeric: tabular-nums;
  text-shadow: 0 0 4px rgba(0, 0, 0, .9);
}
@media (prefers-reduced-motion: reduce) {
  .cam-wheel-ticks { transition: none; }
}
.cam-flash {
  position: absolute; inset: 0; pointer-events: none;
  background: var(--cam-ink); opacity: 0;
}
.cam-flash.on { animation: camflash 180ms ease-out; }
@keyframes camflash { from { opacity: .7; } to { opacity: 0; } }
.cam-fail {
  position: absolute; left: 16px; right: 16px; top: 30%;
  display: flex; flex-direction: column; align-items: center; gap: 14px;
  text-align: center; font-size: var(--fs-lead); line-height: 1.4;
}
.cam-bar {
  flex: none;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  padding: 14px 16px max(14px, env(safe-area-inset-bottom, 0px));
  background: var(--cam-bg);
}
/* Two classes, to outrank .btn.ghost's fill. */
.cam .cam-done {
  justify-self: start; color: var(--cam-ink); border-color: var(--cam-ink);
  background: transparent;
}
.cam-shutter {
  width: 72px; height: 72px; border-radius: 50%;
  border: 4px solid var(--cam-ink); background: var(--cam-ink);
  box-shadow: inset 0 0 0 3px var(--cam-bg);
  cursor: pointer; padding: 0;
}
.cam-shutter:active { transform: scale(.94); }
.cam-shutter:disabled { opacity: .4; cursor: default; }
.cam-side {
  justify-self: end; display: flex; align-items: center; gap: 14px;
}
.cam-count {
  font-size: var(--fs-body); font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.cam-phone {
  width: 44px; height: 44px; border-radius: 50%;
  border: 1px solid var(--cam-ink); color: var(--cam-ink);
  display: flex; align-items: center; justify-content: center; cursor: pointer;
}
.cam-phone .ic { width: 22px; height: 22px; }
@media (prefers-reduced-motion: reduce) {
  .cam-flash.on { animation: none; }
  .cam-shutter:active { transform: none; }
}

/* The viewer trace, shown for selecting by hand where the clipboard is refused. */
.tracecopy {
  width: 100%; height: 120px; margin-top: 8px;
  font: var(--fs-small) / 1.3 ui-monospace, Menlo, Consolas, monospace;
  background: var(--field); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--r-sm);
}

/* =====================================================================
   THE PROPERTIES BAR - a shape's colour, fill, opacity, weight and line
   style, docked across the top of the drawing like Bluebeam's
   Properties toolbar. Built by portal-tools; `--props-top` is the
   drawing area's top edge, measured, because the bar above it changes
   height with the safe area and the number of rows.
   ===================================================================== */
.propsbar {
  /* Centred by auto margins rather than left 50% and a transform: a
     shrink-to-fit box offset to the middle only gets the right half of
     the panel to size itself in, and wrapped to three rows on a phone. */
  position: absolute; z-index: 7;
  top: var(--props-top, 60px); left: var(--props-l, 0px); right: var(--props-r, 0px);
  margin: 0 auto; width: fit-content;
  display: flex; flex-direction: column; align-items: stretch;
  gap: 5px; padding: 5px 6px; max-width: calc(100% - var(--props-l, 0px) - var(--props-r, 0px) - 16px);
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--r-md);
  box-shadow: var(--shadow-2, 0 3px 14px rgba(0,0,0,.28));
}
.proprow { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 4px; }
.propbtn > .ic:not(.propcaret) { width: 20px; height: 20px; }
.propsname {
  font-family: var(--font-head); font-weight: 600; font-size: var(--fs-note);
  padding: 0 6px; white-space: nowrap;
}
.propbtn {
  display: inline-flex; align-items: center; gap: 4px;
  height: 34px; padding: 0 6px; border: 1px solid var(--line);
  border-radius: var(--r-sm); background: var(--bg-2); color: var(--ink);
  cursor: pointer; font: inherit; font-size: var(--fs-note);
}
.propbtn:disabled { opacity: .45; cursor: default; }
.propbtn[aria-expanded="true"] { border-color: var(--accent); background: var(--accent-lo); }
.propbtn .propcaret { width: 12px; height: 12px; stroke-width: 2; }
.propswatch {
  display: block; width: 20px; height: 20px; border-radius: var(--r-xs);
  background: var(--swatch, #000); box-shadow: inset 0 0 0 1px rgba(0,0,0,.25);
}
/* No fill: an empty square with a diagonal, the usual sign for "none". */
.propswatch.none {
  background: linear-gradient(to top right, transparent calc(50% - 1px), var(--bad) calc(50% - 1px),
    var(--bad) calc(50% + 1px), transparent calc(50% + 1px)), var(--bg);
}
.propvalue { min-width: 34px; text-align: center; font-variant-numeric: tabular-nums; }
.proplinepv { display: block; width: 52px; height: 14px; flex: none; }
.propdefault { min-height: 34px; white-space: nowrap; }

.propmenu {
  position: absolute; z-index: 8; top: calc(100% + 4px); left: var(--menu-left, 0px);
  display: flex; flex-direction: column; min-width: 190px; max-height: 60vh;
  overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain;
  padding: 4px; background: var(--bg); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--r-sm);
  box-shadow: 0 6px 20px rgba(0,0,0,.3);
}
.propitem {
  display: flex; align-items: center; gap: 8px; min-height: 36px;
  padding: 0 10px 0 6px; border: 0; border-radius: var(--r-xs);
  background: none; color: inherit; font: inherit; font-size: var(--fs-note);
  text-align: left; cursor: pointer;
}
.propitem:hover, .propitem:focus-visible { background: var(--accent-lo); }
.propitem .propcheck { width: 16px; height: 16px; stroke-width: 2.2; visibility: hidden; }
.propitem[aria-checked="true"] .propcheck { visibility: visible; }
.propitemlabel { flex: 1; white-space: nowrap; }
.propitem .proplinepv { width: 72px; }
.propitem > .ic:not(.propcheck) { width: 22px; height: 22px; flex: none; }
.propgrid {
  display: grid; grid-template-columns: repeat(8, 22px); gap: 5px;
  padding: 8px 6px;
}
.propgridswatch {
  width: 22px; height: 22px; padding: 0; border: 0; border-radius: var(--r-xs);
  background: var(--swatch, #000); box-shadow: inset 0 0 0 1px rgba(0,0,0,.25); cursor: pointer;
}
.propgridswatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent); }
.propcustom {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  padding: 6px 10px; font-size: var(--fs-note); cursor: pointer;
}
.propcustom input[type="color"] { width: 36px; height: 26px; padding: 0; border: 1px solid var(--line); }

/* LINE WEIGHT, as Bluebeam's: a spinner, the value in points, and the
   list of common weights behind a caret. */
.propweight {
  display: inline-flex; align-items: stretch; height: 34px;
  border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--bg-2);
}
.propspin { display: flex; flex-direction: column; border-right: 1px solid var(--line); }
.propspinbtn {
  flex: 1; width: 20px; padding: 0; border: 0; background: none; color: var(--ink);
  display: flex; align-items: center; justify-content: center; cursor: pointer;
}
.propspinbtn + .propspinbtn { border-top: 1px solid var(--line); }
.propspinbtn:active { background: var(--accent-lo); }
.propspinic { width: 11px; height: 11px; stroke-width: 2.2; }
.propspinic.up { transform: rotate(180deg); }
/* Stated in full: the global text-input rule sizes every field for a
   form, and this one sits inside a 34 px control. */
.propweight input.propnum {
  width: 66px; min-width: 0; height: auto; min-height: 0; margin: 0;
  border: 0; border-radius: 0; padding: 0 6px; box-shadow: none;
  background: var(--field); color: var(--ink); font: inherit; font-size: var(--fs-note);
  text-align: right; font-variant-numeric: tabular-nums;
}
.propweight input.propnum:focus { outline: 2px solid var(--accent); outline-offset: -2px; box-shadow: none; }
.propweight .proplistbtn { height: auto; border: 0; border-left: 1px solid var(--line); border-radius: 0; padding: 0 4px; }

/* A plain choice in a docked window - the Count tool's group. */
.proprow select.propselect {
  width: auto; min-width: 140px; max-width: 220px; min-height: 0; height: 34px;
  padding: 0 8px; font-size: var(--fs-note); border-width: 1px;
}

/* The selection's actions, on their own row under the controls. */
.propactions {
  display: flex; justify-content: center; gap: 6px;
  border-top: 1px solid var(--line); padding-top: 5px;
}
.propact {
  width: 36px; height: 32px; padding: 0; border: 1px solid var(--line);
  border-radius: var(--r-sm); background: var(--bg-2); color: var(--ink);
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
}
.propact .ic { width: 18px; height: 18px; }
.propact:active { background: var(--accent-lo); }
.propact.danger { color: var(--bad); }

/* A phone: no name, shorter previews, so the five controls share a row. */
@media (max-width: 560px) {
  .propsname { display: none; }
  .propbtn { padding: 0 4px; gap: 2px; }
  .propbtn > .proplinepv { width: 34px; }
}

/* The status toast steps down out from under the bar. */
.hasprops .vwstage .vwmsg { top: 58px; }

/* Compare, over the whole viewer. The pages sit on white paper and both
   revisions multiply, so red and blue linework that coincides reads near
   black and a fade blends towards white, not the grey stage. */
.vwcmp {
  position: fixed; inset: 0; z-index: 30;
  display: flex; flex-direction: column;
  background: var(--bg); color: var(--ink);
}
.vwcmp-bar {
  display: flex; align-items: center; gap: 8px; flex: none; flex-wrap: wrap;
  padding: 8px 10px; padding-top: max(8px, env(safe-area-inset-top));
  background: var(--navy); color: #fff;
}
.vwcmp-title { font-family: var(--font-head); font-weight: 600; margin-right: auto; }
.vwcmp-pick { display: inline-flex; align-items: center; gap: 6px; }
.vwcmp-pick select { min-height: 36px; width: auto; max-width: 42vw; padding-top: 4px; padding-bottom: 4px; font-size: var(--fs-body); }
.vwcmp-swatch { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex: none; }
.vwcmp-swatch.old { background: var(--ink-red); }
.vwcmp-swatch.new { background: var(--ink-blue); }
.vwcmp-stage {
  position: relative; flex: 1; min-height: 0; overflow: hidden;
  background: #6b7280; touch-action: none; cursor: grab;
  -webkit-user-select: none; user-select: none;
}
.vwcmp-stage:active { cursor: grabbing; }
.vwcmp-world { position: absolute; top: 0; left: 0; transform-origin: 0 0; will-change: transform; }
.vwcmp-paper { position: absolute; top: 0; left: 0; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,.35); }
/* One revision: its base canvas and, once the view rests, a sharper
   detail canvas over the visible part. The group multiplies as one, so
   the detail covers the soft base beneath it rather than blending with it. */
.vwcmp-side { position: absolute; top: 0; left: 0; transform-origin: 0 0; mix-blend-mode: multiply; pointer-events: none; }
.vwcmp-side canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; background: #fff; }
.vwcmp-msg {
  position: absolute; left: 50%; top: 12px; transform: translateX(-50%);
  max-width: min(84%, 460px); padding: 7px 12px; border-radius: 999px;
  background: rgba(17,24,39,.78); color: #fff; font-size: var(--fs-note);
  text-align: center; pointer-events: none;
}
.vwcmp-msg:empty { display: none; }
.vwcmp-msg.bad { background: rgba(153,27,27,.88); }
.vwcmp-foot {
  display: flex; align-items: center; justify-content: space-between; gap: 10px 16px; flex: none; flex-wrap: wrap;
  padding: 8px 12px; padding-bottom: max(8px, var(--safe-b, env(safe-area-inset-bottom)));
  border-top: 1px solid var(--line); background: var(--bg);
}
.vwcmp-fade { display: flex; align-items: center; gap: 8px; flex: 1 1 260px; min-width: 0; }
.vwcmp-fade input[type="range"] { flex: 1; min-width: 0; }
.vwcmp-end { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-note); font-weight: 600; color: var(--ink-2); }
.vwcmp-nudge { display: flex; align-items: center; gap: 6px; }
.vwcmp-offset { font-size: var(--fs-note); color: var(--ink-2); font-variant-numeric: tabular-nums; margin-right: 4px; white-space: nowrap; }
.ic.rot90 { transform: rotate(90deg); }
.ic.rot180 { transform: rotate(180deg); }
.ic.rot270 { transform: rotate(-90deg); }


/* ---- Shared projects: members, presence, activity ---- */

/* `--who` is the person's colour from C.presenceColour, set per element. */
.collab-members { display: flex; flex-direction: column; }
.collab-member {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 0; border-bottom: 1px solid var(--line-2);
}
.collab-member:last-child { border-bottom: 0; }
.collab-member-name { flex: 1; min-width: 0; font-size: var(--fs-field); overflow-wrap: anywhere; }
.collab-member-role { font-size: var(--fs-small); color: var(--ink-2); }
.collab-member .btn { flex: none; }
.collab-add { margin-top: 10px; }

/* The Share button sits at the end of the project's button row. */
.stackbtns .push-end { margin-left: auto; }
.modal.collab { z-index: 60; }
.collab-head { display: flex; align-items: center; gap: 8px; }
.collab-head h2 { flex: 1; margin: 0; font-size: var(--fs-title); }
.collab-head .btn { flex: none; }

.avatar {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; border-radius: 50%;
  background: var(--who, var(--accent)); color: #fff;
  font: 600 var(--fs-small) / 1 var(--font-head);
  border: 2px solid var(--bg);
}

.collab-banner {
  display: flex; align-items: center; gap: 10px;
  margin: 0 0 12px; padding: 10px 12px;
  background: var(--accent-lo); color: var(--ink);
  border-radius: var(--r-md); font-size: var(--fs-note);
}
.collab-banner-text { flex: 1; min-width: 0; }
.collab-banner .ic { flex: none; width: 20px; height: 20px; }
.collab-banner .btn { flex: none; }

/* Sync conflicts: the banner reads as a warning, since export waits on it. */
.collab-banner.conflict-banner { background: var(--warn-bg); color: var(--warn); }

.modal.conflict { z-index: 60; }
.conflict-list { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.conflict-item {
  padding: 12px; border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--bg-2);
}
.conflict-title {
  margin: 0 0 4px; font-family: var(--font-head); font-weight: 600;
  font-size: var(--fs-field); overflow-wrap: anywhere;
}
.conflict-item .hint { margin: 0 0 8px; }
.conflict-fields { display: flex; flex-direction: column; gap: 8px; }
.conflict-field { display: flex; flex-direction: column; gap: 4px; }
.conflict-field-name { font-size: var(--fs-small); font-weight: 600; color: var(--ink-2); }
.conflict-pick {
  display: flex; align-items: flex-start; gap: 8px;
  padding: 6px 8px; border-radius: var(--r-sm);
  background: var(--bg); border: 1px solid var(--line-2);
  font-size: var(--fs-note); cursor: pointer;
}
.conflict-pick:has(input:checked) { border-color: var(--accent); background: var(--accent-lo); }
.conflict-pick input { margin: 2px 0 0; flex: none; }
.conflict-side { flex: none; width: 52px; font-weight: 600; }
.conflict-val { flex: 1; min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.conflict-item .stackbtns { margin-top: 10px; }

/* Work offline: a dot on the account chip says sync is switched off. */
body.work-offline .appid .acctbtn::after {
  content: ''; position: absolute; right: -3px; bottom: -3px;
  width: 10px; height: 10px; border-radius: 50%;
  background: var(--warn-bg); border: 2px solid var(--warn);
}

.pj-row .flag.collab-new { background: var(--accent); color: var(--on-accent); }
.pj-row .flag.collab-shared { background: var(--accent-lo); color: var(--ink); }

/* A dot on a sheet another person has changed since it was last opened. */
.collab-dot {
  display: inline-block; width: 8px; height: 8px; border-radius: 50%;
  background: var(--accent); margin-left: 6px; vertical-align: middle;
}

.collab-toast {
  position: fixed; left: 50%; bottom: calc(84px + env(safe-area-inset-bottom));
  transform: translateX(-50%); z-index: 70;
  max-width: min(92vw, 420px); padding: 10px 14px;
  background: var(--accent-fill); color: var(--on-accent); border: 0;
  border-radius: var(--r-md); box-shadow: var(--shadow-2);
  font: var(--fs-note) / 1.35 var(--font); text-align: left; cursor: pointer;
}

/* The viewer's people bar: overlapping faces, tap to follow. */
.vwpeople { display: flex; align-items: center; flex: none; }
.vwpeople .avatar { margin-left: -8px; cursor: pointer; padding: 0; }
.vwpeople .avatar:first-child { margin-left: 0; }
.vwpeople .avatar[aria-pressed="true"] { outline: 2px solid var(--brand-sky); outline-offset: 1px; }

.vwfollow {
  display: flex; align-items: center; gap: 8px; flex: none;
  padding: 6px 10px; background: var(--accent-lo); color: var(--ink);
  border-bottom: 1px solid var(--line); font-size: var(--fs-note);
}
.vwfollow > span:nth-child(2) { flex: 1; min-width: 0; }
.vwfollow-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--who, var(--accent)); flex: none; }

.vwpresence { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.vwpresence-ink { position: absolute; inset: 0; width: 100%; height: 100%; }
.vwpresence-ink polyline { fill: none; stroke-linecap: round; stroke-linejoin: round; opacity: .55; }
.vwpointer {
  position: absolute; top: 0; left: 0;
  width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%;
  background: var(--who, var(--accent));
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--who, var(--accent)) 30%, transparent);
}
.vwpointer-name {
  position: absolute; top: 0; left: 0; margin: 10px 0 0 10px;
  padding: 2px 6px; border-radius: var(--r-xs);
  background: var(--who, var(--accent)); color: #fff;
  font-size: var(--fs-cap); white-space: nowrap;
}

/* A text box someone else is typing in. */
.vwtextlock {
  position: absolute; top: 0; left: 0; pointer-events: none;
  padding: 2px 6px; border-radius: var(--r-xs);
  background: var(--who, var(--accent)); color: #fff; font-size: var(--fs-cap); white-space: nowrap;
}
