:root { --bg:#101828; --card:#1c2740; --ink:#f2f5fa; --mut:#9aa7bd; --acc:#3b82f6; --ok:#22c55e; --warn:#eab308; --err:#ef4444; }
/* ---------- MOTION ----------
   FOUR PROPERTIES, so the whole system is one edit to revert and one place to
   retune. Three durations because this app only ever needs three answers — a
   press, a state change, a thing arriving — and a fourth would just be a fifth
   opinion about what "quick" means. `--ease` is the curve the sheet was already
   using, taken as the app's one signature rather than letting each surface pick
   its own; the camera's own `ease` timings are left alone deliberately, because
   that overlay is a separate place with its own conventions.

   THE RULE EVERYWHERE BELOW: `opacity` and `transform` only. Both are composited,
   so nothing here can cost a layout on a phone holding a live findings list. The
   one exception is the Sent section's collapse, which has to touch height, and it
   says so where it is written. */
:root { --dur-1:.12s; --dur-2:.18s; --dur-3:.22s; --ease:cubic-bezier(.2,.8,.2,1); }
* { box-sizing: border-box; }
body { margin:0; font:16px/1.45 -apple-system, system-ui, sans-serif; background:var(--bg); color:var(--ink); }
/* THE PAGE IS ONE SCROLLER, and it always has been — there is no 100vh shell
   and no inner list with its own overflow anywhere on the technician's screen.
   What was actually wrong was the bottom: a flat 60px of padding with no idea
   that a phone has a home indicator and a URL bar, so the last card in a long
   waiting list ended up under one or the other and its Send button could not be
   tapped. The insets are what fix that, and they are zero on every device that
   does not need them. viewport-fit=cover in index.html is what makes env()
   report anything at all. */
.wrap { max-width:480px; margin:0 auto;
        padding:calc(env(safe-area-inset-top) + 20px) 16px calc(env(safe-area-inset-bottom) + 60px); }
h1 { font-size:22px; margin:8px 0 2px; }
.sub { color:var(--mut); font-size:13px; margin-bottom:18px; }
.card { background:var(--card); border-radius:14px; padding:16px; margin-bottom:14px; }
label { display:block; font-size:13px; color:var(--mut); margin:12px 0 5px; font-weight:600; }
/* font-family is inherited on purpose: a bare <textarea> otherwise renders in
   the browser's monospace default, which made the typed-note box the one thing
   on the capture card that did not look like the app. 16px is what keeps iOS
   from zooming the page when a field takes focus. */
input, select, textarea { width:100%; padding:11px 12px; border-radius:10px; border:1px solid #33405e; background:#0d1526; color:var(--ink); font-family:inherit; font-size:16px; }
textarea { min-height:70px; }
/* The transition is on EVERY button in the app, and it buys two things. The
   press below, and the moment a disabled control wakes up: `.sh-go` coming
   alive when a destination is picked was a jump from 45% to full ink in one
   frame, which reads as the screen redrawing rather than as an answer being
   accepted. Rules further down that declare their own `transition` replace this
   one wholesale — deliberate, they are the surfaces with something more
   specific to say. */
button { width:100%; padding:13px; margin-top:14px; border:0; border-radius:12px; background:var(--acc); color:#fff; font-size:16px; font-weight:700; cursor:pointer;
         transition:opacity var(--dur-2) var(--ease), transform var(--dur-1) var(--ease); }
button:disabled { opacity:.45; }
/* ---------- THE PRESS ----------
   Extending the idiom the camera already had (.cam-shutter:active scales to
   .93). TWO SIZES AND NOTHING BETWEEN THEM, because the amount is a function of
   the surface: a full-width primary moving 1.5% moves eight pixels of edge and
   reads as plenty, while the same 1.5% on a 44px round control is invisible
   under the thumb that is covering it. Everything here is a technician
   confirming, one-handed and possibly gloved, that the tap landed. */
.cap:active, .sh-go:active, #loginBtn:active, .senderr:active,
.allbtn:active, .act .full:active { transform:scale(.985); }
.uf-del:active, .toast-undo:active, .foot-btn:active,
.uf .thumbs .th:active, .se-side .th:active { transform:scale(.94); }
/* The record button, in iOS's own idiom: a red dot at rest that becomes a red
   square while recording. Techs look for red, and this is the one control on
   the card that starts a recording.

   THE GLYPH CARRIES THE STATE, not the button chrome. A full-width button
   flooded red for the twenty seconds someone is talking is the loudest thing
   on a dark screen for the least surprising state it can be in — and it leaves
   no contrast headroom for the disabled/busy state that follows it. The tint
   plus the inset rule is what still reads as "live" from across a bay, which
   is the actual requirement: a tech who cannot tell whether it is recording
   either loses a note or records thirty seconds of compressor noise.

   min-height states the 44px-plus touch target rather than leaving it to
   padding-plus-line-height arithmetic that a font change could quietly break.
   tabular-nums stops the running timer from jittering the label's width. */
button.rec { background:#273350; display:flex; align-items:center; justify-content:center; gap:10px; min-height:52px; }
button.rec .dot { flex:0 0 auto; width:16px; height:16px; border-radius:50%; background:var(--err);
                  transition:border-radius .18s ease, width .18s ease, height .18s ease; }
button.rec .lbl { font-variant-numeric:tabular-nums; }
button.rec.on { background:#31212b; box-shadow:inset 0 0 0 1px #7f1d1d; }
button.rec.on .dot { border-radius:3px; width:14px; height:14px; }
button.rec.busy .dot { animation:recpulse 1.1s ease-in-out infinite; }
@keyframes recpulse { 0%,100% { opacity:1 } 50% { opacity:.35 } }
@media (prefers-reduced-motion: reduce) { button.rec.busy .dot { animation:none; opacity:.5; } }
button.ghost { background:transparent; color:var(--mut); font-weight:500; padding:8px; margin-top:6px; }
.hint { font-size:12px; color:var(--mut); margin-top:6px; }
/* .dim went with the work-order bar's hint line (2026-08-07) — it existed to
   fade a stale provenance line, and nothing sets it any more. */
/* The work-order bar. Slim and STICKY rather than a hero card, because it has
   two jobs at once now: it is the number every finding is filed against, and
   it is the thing the auto-detect can change by itself while the technician is
   reading the waiting list further down. A number that scrolls away can roll
   onto the next car unwatched, which is the one mistake nothing downstream
   catches. Tabular figures stop the digits shifting as the number is replaced
   from the desktop. The font stays well over 16px, which is what keeps iOS from
   zooming the page on focus.

   Two rows became one (2026-08-07): no label column, no hint line under it, so
   the card's padding is symmetric again and the whole bar is the number, the
   car, and the way to re-read them. */
/* top is the inset rather than 0: in a home-screen PWA the document runs under
   the status bar, and a sticky bar pinned to 0 parks itself under the clock. */
.wobar { position:sticky; top:env(safe-area-inset-top); z-index:30; background:var(--bg);
         padding:6px 0 10px; margin:0 0 12px; }
.wocard { position:relative; background:var(--card); border-radius:13px; padding:10px 12px; }
.wo-row { display:flex; align-items:center; gap:6px; }
/* AN EMPTY COLUMN, ON PURPOSE, and it is the whole of what makes the number
   look centred. Centring inside the space LEFT OVER after the trailing button
   is not centring: the eye measures against the card, so the number sat half a
   button to the left and read as a mistake. This balances the control with
   nothing, the way an iOS navigation bar reserves a leading slot it may not
   use. Its width must track the button's below.

   A ⋯ menu occupied this slot for one afternoon on 2026-08-07 and was taken
   back out — a control here is as far from a thumb as the screen allows. The
   spacer had to come back with it, or the estimate number goes off-centre by
   half a button and reads as a bug. */
.wo-row::before { content:''; flex:0 0 44px; }
/* Read-only since phase 11 — no input here any more. The number keeps the
   field's old weight and tabular figures so it stays legible at arm's length
   in a bay, and the vehicle sits under it because that is the half a
   technician actually recognises: a number is easy to misread, a Mercedes is
   not. The vehicle line is hidden outright when there is no vehicle, and the
   width the "ESTI/MATE" label used to hold is now the vehicle's — a model name
   runs to 60 characters and this is the line that was losing the argument. */
.wo-read { flex:1; min-width:0; text-align:center; }
.wo-num { font-size:21px; font-weight:800; font-variant-numeric:tabular-nums; line-height:1.2; }
.wo-veh { font-size:12.5px; font-weight:600; color:var(--mut); margin-top:2px;
          overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* A bare glyph at the trailing edge, not a filled tile — the tile made the one
   thing on this strip nobody needs to look at into the loudest thing on it.
   iOS puts a reload exactly here and gives it no chrome until it is pressed, so
   the 44px is TAP TARGET rather than paint: the icon is 19px and the rest is
   the invisible margin Apple asks for. The pressed tile is the whole feedback
   story, because a re-read that changes nothing is otherwise silent and the
   tech cannot tell the tap landed. flex on .ic centres the glyph AND puts the
   spin's rotation origin at the icon's own centre. */
.wo-row button { width:44px; flex:0 0 44px; height:44px; margin:0; padding:0; border-radius:12px;
                 background:transparent; color:var(--ink); display:flex; align-items:center; justify-content:center;
                 touch-action:manipulation; -webkit-tap-highlight-color:transparent; transition:background .15s; }
.wo-row button:active { background:#273350; }
.wo-row button .ic { display:flex; }
.wo-row button.busy .ic { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- THE FOOT OF THE PAGE ----------
   Sign out and the build stamp. Deliberately SMALL and centred: this is the
   least-used control in the app and the most expensive to hit by accident, so
   it gets none of the weight a primary action gets. Not full-width either —
   every other button in this column runs edge to edge, and matching them would
   make it look like one more thing to do.

   Reached by scrolling past every finding, which is the whole point. It sat
   under "Capture finding" (a gloved misfire ended the session mid-car) and then
   for one afternoon in a menu beside the refresh icon (the far corner from a
   thumb); both were reachable by accident on the way to something else, and
   this is not. */
.foot { margin-top:22px; text-align:center; }
.foot-btn { width:auto; margin:0; padding:9px 20px; min-height:40px; border-radius:99px;
            border:1px solid #35446a; background:transparent; color:var(--mut);
            font-size:13.5px; font-weight:600; }
.foot-btn:active { background:#273350; }
/* The confirmation, inline rather than a browser confirm(): a native modal
   blocks the page, looks nothing like the app, and on iOS titles itself with
   the origin rather than the thing being confirmed. */
.foot-ask[hidden] { display:none; }
.foot-q { font-size:12.5px; color:var(--mut); line-height:1.5; margin:0 auto 10px; max-width:300px; }
.foot-row { display:flex; justify-content:center; gap:10px; }
/* The destructive half is the one that is coloured, and it is the SECOND of the
   two — Cancel sits where the thumb lands first. */
.foot-btn.danger { border-color:#7f1d1d; background:#3a2222; color:#fca5a5; font-weight:700; }
.foot-btn.danger:active { background:#4a2a2a; }
/* The "desktop is on RO … — tap to use" chip is gone (phase 11). It existed to
   reconcile a detected work order against one a technician had typed, and
   nothing types one now — the detected value IS the value. */
.err { color:#fca5a5; font-size:13px; margin-top:10px; white-space:pre-wrap; }
/* A quiet aside on the login card — no longer a link anywhere, because /pair
   is a redemption endpoint a human should never land on by hand (2026-08-06). */
.quiet { color:var(--mut); font-size:13px; margin-top:12px; }
/* The build stamp. Small enough to ignore, findable enough to answer "which
   version is this phone on" — the question a stale artifact made unanswerable
   and cost a whole debugging cycle. */
.build { color:var(--mut); font-size:11px; opacity:.6; text-align:center; margin-top:10px; font-variant-numeric:tabular-nums; }
/* /pair's working state: the spinner IS the page for the second it takes. */
.signin { display:flex; align-items:center; gap:10px; }
.spinner { flex:0 0 auto; width:16px; height:16px; border-radius:50%;
           border:2px solid #33405e; border-top-color:var(--acc); animation:spin .8s linear infinite; }
/* The dead-module escape hatch, revealed by animation delay because CSS is the
   only thing still running when a module throws at import. 0s duration + 8s
   delay + forwards = it simply appears at 8s. 8s because the real path takes
   about one: long enough that nobody on a slow bay connection reads it as a
   fault, short enough that a tech standing at a car has not yet given up. */
.stuck { margin-top:14px; font-size:13px; color:var(--mut); opacity:0;
         animation: reveal 0s linear 8s forwards; }
@keyframes reveal { to { opacity:1; } }
.pgrid { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
/* The tile itself now opens the photo full-size (js/viewer.js) — until 2026-08-07
   there was no way to look at one at all, and a blurred shot was
   indistinguishable from a sharp one until it was already on a customer's
   repair order. */
.pgrid .ph { position:relative; width:72px; height:72px; border-radius:10px; overflow:hidden; background:#0d1526;
             cursor:pointer; -webkit-tap-highlight-color:transparent; }
.pgrid .ph img { width:100%; height:100%; object-fit:cover; display:block; }
.pgrid .ph .rm { position:absolute; top:2px; right:2px; width:22px; height:22px; padding:0; margin:0; border-radius:50%; background:rgba(0,0,0,.65); color:#fff; font-size:14px; line-height:22px; font-weight:400; }
/* 22px of paint, 36px of target, on both grids. This app commits to 44px for a
   gloved thumb (see the .wo-row comment) and this DELIBERATELY falls short: the
   tile underneath now opens the viewer, and a 44px ✕ on a 72px tile would
   swallow the region that opens it. 36 is the largest that leaves a usable
   open-the-photo area, and the shortfall is a real trade rather than an
   oversight. */
.pgrid .ph .rm::after, .cam-sh .rm::after { content:''; position:absolute; top:50%; left:50%;
                                            width:36px; height:36px; transform:translate(-50%,-50%); }
.pgrid .add { width:72px; height:72px; margin:0; padding:0; border-radius:10px; background:#273350; color:var(--mut); font-size:11px; font-weight:700;
              display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; }
.pgrid .add span { font-size:20px; font-weight:400; line-height:1; }
/* The capture spine: the note itself, then the button that dictates into it,
   then the one primary button, then the sentence that is the whole promise of
   this screen. `.typed` used to sit BELOW the record button as the
   if-the-bay-is-loud escape hatch; it is above it now because it is where the
   words land, and it is the note whichever way they got there. Taller, too —
   it holds a whole finding now, not a fallback sentence. */
.typed { margin-top:10px; min-height:96px; }
.cap { margin-top:18px; padding:16px; font-size:17px; font-weight:800; }
/* .capsub went with the "nothing goes to Shop-Ware until you send it" line
   (2026-08-07). The waiting list says the same thing in its own words, twelve
   pixels lower, where the unsent findings actually are. */
/* ---------- the in-app camera overlay (js/camera.js) ----------
   Dark, full-bleed, thumb-first: a technician holds this one-handed in a bay,
   possibly gloved, so every control sits at an edge a thumb reaches and none of
   them is small. Fixed rather than a separate page, because the capture card
   underneath has to keep its work order, its voice note and its checklist
   choice while the camera is up. Buttons here override the global button rule
   one by one — that rule is full-width and 13px-padded, which is right for a
   form card and wrong for every control on a camera. */
body.cam-on { overflow:hidden; }
/* touch-action:none is half of "pinch zooms the camera, not the page", and it
   is a PRECONDITION rather than a nicety: without it the browser claims the
   gesture for page zoom and pointermove never fires, so there is nothing to
   drive the lens with. The other half lives in js/camera.js — WebKit routes
   page pinch-zoom through non-standard gesture* events that touch-action does
   not touch, and preventing those is what actually fixes it.
   overflow:hidden keeps a digitally-zoomed video from making the viewport
   scrollable. 100dvh rather than 100vh so the iOS URL bar cannot clip the
   bottom row, which is now where every control a thumb needs lives. */
.cam { position:fixed; inset:0; height:100dvh; z-index:50; background:#000; display:flex; flex-direction:column;
       overscroll-behavior:none; overflow:hidden; touch-action:none; -webkit-tap-highlight-color:transparent; }
/* THE LETTERBOX (2026-08-07). js/camera.js sizes this to the frame's own aspect
   ratio, so the whole picture is visible and the controls sit over black bands
   — the shape Apple's camera app has, and for the same reason: the technician
   has to be framing the image the repair order will get. Before this the video
   was object-fit:cover across the whole screen, which cropped roughly two
   thirds of a 4:3 stream's width out of the PREVIEW while the capture kept all
   of it.
   overflow:hidden is the second half and is not cosmetic: it clips the digital
   zoom to this box, so what is on screen is exactly the centred 1/factor crop
   js/photos.js takes. A plain object-fit:contain video would grow past the
   letterbox under scale() and show more height than the photo keeps.
   The 100%/100% default is what is on screen for the first frame or two, before
   videoWidth is known — cover behaviour, and better than a zero-sized box. */
.cam-stage { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
             width:100%; height:100%; overflow:hidden; background:#000; }
/* Its own compositor layer, so digital zoom animates on the GPU instead of
   repainting the frame. js/camera.js keeps the translateZ(0) in the value it
   writes, not only here. cover rather than contain because the stage already
   matches the frame's shape — the two are the same fit here, and cover is the
   one focusAt()'s mapping is written against. */
.cam-stage video { display:block; width:100%; height:100%; object-fit:cover;
                   transform:translateZ(0); transform-origin:center; will-change:transform; }
/* The fire confirmation. The web has no shutter SOUND, so the flash and the
   button's squash are the only proof a photo was taken. */
.cam-flash { position:absolute; inset:0; background:#fff; opacity:0; pointer-events:none; }
.cam-flash.on { animation: camflash .18s ease-out; }
@keyframes camflash { from { opacity:.85 } to { opacity:0 } }
/* Drawn ONLY after applyConstraints accepted the point of interest — a reticle
   over an autofocus we are not driving teaches a gesture that does nothing. */
.cam-focus { position:absolute; width:72px; height:72px; margin:-36px 0 0 -36px; border:2px solid #fff;
             border-radius:50%; opacity:0; pointer-events:none; }
.cam-focus.on { animation: camfocus .9s ease-out; }
@keyframes camfocus { 0% { opacity:0; transform:scale(1.4) } 15% { opacity:1; transform:scale(1) } 100% { opacity:0 } }
.cam-top { position:relative; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:8px;
           padding:calc(env(safe-area-inset-top) + 6px) 8px 10px; background:linear-gradient(#000b,#0000); }
/* Also the long-press handle for #camDiag. The padding is the target and the
   user-select rules stop iOS answering a 600ms press with a text selection and
   a callout menu instead. */
.cam-count { font-size:14px; font-weight:700; font-variant-numeric:tabular-nums; padding:6px 10px 6px 0;
             -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
.cam-txt { width:auto; margin:0; padding:8px 14px; min-height:44px; background:transparent; color:#fff;
           font-size:15px; font-weight:600; transition:opacity .12s ease; }
.cam-txt:active { opacity:.55; }
.cam-txt.cam-go { color:#7dd3fc; font-weight:800; }
.cam-native { font-size:13px; font-weight:500; color:#ffffffaa; padding:8px 10px; }
/* The ONE element claiming the free space. It replaced a margin-top:auto on
   .cam-tools, which carried a warning that a second auto margin in this flex
   column would share the space and strand a row in the middle — the warning was
   right, and the layout now has three rows rather than two. flex:1 on exactly
   one child cannot go wrong the same way, so the trap is removed rather than
   worked around. */
.cam-mid { position:relative; z-index:2; flex:1; min-height:0; display:flex; align-items:flex-end; }
.cam-tools { display:flex; align-items:center; gap:14px; padding:0 16px 12px; }
.cam-tool { width:52px; height:52px; margin:0; padding:0; border-radius:50%; background:#0009; color:#fff;
            display:flex; align-items:center; justify-content:center; transition:background .12s ease; }
.cam-tool.on { background:var(--warn); color:#3a2c00; }
.cam-bottom { position:relative; z-index:2; padding:0 12px calc(env(safe-area-inset-bottom) + 12px);
              background:linear-gradient(#0000, #000c 35%); }
.cam-err { color:#fca5a5; font-size:13px; text-align:center; padding:0 8px 4px; }
/* This session's shots only — the same boundary Cancel draws. Scrolls sideways
   so an eight-photo session still leaves the shutter where the thumb expects. */
/* pan-x carves the strip back out of .cam's touch-action:none — without it the
   filmstrip stops scrolling sideways and an eight-photo session is unreachable. */
.cam-strip { display:flex; gap:8px; overflow-x:auto; padding:6px 0; scrollbar-width:none; touch-action:pan-x; }
.cam-strip::-webkit-scrollbar { display:none; }
.cam-strip:empty { display:none; }
.cam-sh { position:relative; flex:0 0 auto; width:60px; height:60px; border-radius:8px; overflow:hidden;
          background:#111; border:1px solid #ffffff33; cursor:pointer; }
.cam-sh img { width:100%; height:100%; object-fit:cover; display:block; }
/* Still in the prepare queue: dim and breathing, rather than a blank tile or a
   broken-image icon. It does not open the viewer yet — there is no 1600px file
   to draw for another ~300ms. */
.cam-sh.pending { opacity:.55; animation: campulse 1s ease-in-out infinite; }
@keyframes campulse { 50% { opacity:.28 } }
.cam-sh .rm { position:absolute; top:1px; right:1px; width:22px; height:22px; padding:0; margin:0; border-radius:50%;
              background:rgba(0,0,0,.65); color:#fff; font-size:13px; line-height:22px; font-weight:400; }
/* Horizontal, full width, above the chips. A vertical edge slider was
   considered and dropped: this is the control that was already here, and what
   it needed was to be smooth and legible rather than to move. The thumb is
   oversized because this is operated with a glove on. */
.cam-zoomrow { padding:4px 8px 2px; }
.cam-zoomrow:empty { display:none; }
.cam-zoom { display:block; width:100%; height:26px; margin:0; padding:0; border:0; background:transparent;
            -webkit-appearance:none; appearance:none; touch-action:none; }
.cam-zoom::-webkit-slider-runnable-track { height:4px; border-radius:99px; background:#ffffff55; }
.cam-zoom::-webkit-slider-thumb { -webkit-appearance:none; width:22px; height:22px; margin-top:-9px;
                                  border-radius:50%; background:#fff; box-shadow:0 1px 4px #000a; }
.cam-zoom::-moz-range-track { height:4px; border-radius:99px; background:#ffffff55; }
.cam-zoom::-moz-range-thumb { width:22px; height:22px; border:0; border-radius:50%; background:#fff; }
/* The reference is Apple's Camera app: the ACTIVE value sits in a filled dark
   circle with its label in the accent colour and an × suffix, and the inactive
   ones are bare numerals in white with no container at all. The × appearing
   only on the active chip is what makes this read as a camera rather than as a
   segmented control — js/camera.js writes the labels, which is where that lives.
   Only presets the phone can actually deliver are ever built. */
.cam-presets { display:flex; align-items:center; justify-content:center; gap:12px; padding:2px 0 6px; }
.cam-presets:empty { display:none; }
.cam-presets.busy { opacity:.5; pointer-events:none; }
.cam-p { width:auto; min-width:44px; height:44px; margin:0; padding:0; border-radius:50%; background:transparent;
         color:#fff; font-size:14px; font-weight:600; font-variant-numeric:tabular-nums;
         transition:background .12s ease, color .12s ease, transform .12s ease; }
.cam-p.on { width:44px; min-width:44px; background:#000000a6; color:var(--warn); font-weight:700; }
.cam-p:active { transform:scale(.9); }
/* Cancel bottom-left, shutter centred, Done bottom-right — all three inside
   thumb reach, which is the whole point of the 2026-08-07 layout. A grid rather
   than space-between because the shutter has to stay dead centre no matter how
   wide the two text buttons get, and "Finishing…" is wider than "Done". */
.cam-row { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; padding-top:2px; }
.cam-row .cam-txt:first-child { justify-self:start; }
.cam-row .cam-txt:last-child { justify-self:end; }
.cam-shutter { display:block; width:76px; height:76px; margin:0; padding:0; border-radius:50%;
               background:#fff; border:4px solid #ffffff55; box-shadow:0 0 0 3px rgba(0,0,0,.4);
               transition:transform .12s ease; }
.cam-shutter:active { transform:scale(.93); }
.cam-shutter.fire { animation: camfire .18s ease-out; }
@keyframes camfire { 50% { transform:scale(.86); background:#cbd5e1; } }
/* ---------- the capability dump (long-press the photo count) ----------
   Above everything else on the overlay, and SELECTABLE — the whole point is
   that a technician can copy it out of a phone nobody in this repo has held.
   Monospace and pre-wrap because it is JSON and the line breaks carry meaning.
   touch-action:auto and the select rules override .cam's blanket suppression,
   which is right for a camera and wrong for a page of text. */
.cam-diag { position:absolute; inset:0; z-index:4; background:#000000e6; overflow:auto;
            touch-action:auto; -webkit-overflow-scrolling:touch;
            padding:calc(env(safe-area-inset-top) + 10px) 12px calc(env(safe-area-inset-bottom) + 16px); }
.cam-diagx { width:auto; margin:0 0 10px; padding:10px 18px; min-height:44px; border-radius:99px;
             background:#ffffff26; color:#fff; font-size:15px; font-weight:700; }
.cam-diagt { margin:0; font-size:11px; line-height:1.45; color:#e2e8f0; white-space:pre-wrap;
             word-break:break-word; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
             -webkit-user-select:text; user-select:text; }
/* ---------- the full-quality photo viewer (js/viewer.js) ----------
   z-index above .cam's 50: it opens over the LIVE camera as well as over the
   capture card, and it is one component serving both lists. touch-action:none
   for the same reason as .cam — it is what lets pointermove see the pinch at
   all. object-fit:contain, not cover: this is the one place the whole frame
   has to be visible, because the technician is deciding whether it is good
   enough to send. */
.vw { position:fixed; inset:0; height:100dvh; z-index:60; background:#000; overflow:hidden;
      touch-action:none; overscroll-behavior:none; -webkit-tap-highlight-color:transparent; }
/* THREE SLIDES ON ONE TRACK, moved as a unit and only ever with a transform —
   never left/margin, which would relayout three full-screen images per frame.
   The track is the width of one screen and its three flex children overflow it
   to the right; js/viewer.js positions it in pixels, so the resting place is
   translate3d(-100vw) with the middle slide on screen.
   This replaced a single <img> whose src was swapped on every page change. That
   is why swiping felt janky and it was structural, not a tuning problem: with
   no adjacent image there was nothing to follow the finger with, and the next
   photo had to be decoded before it could appear at all. */
/* translate3d(0,0,0) rather than a will-change toggled by JS on touch and off
   again on release. That toggle was promoting and demoting the compositor layer
   UNDER THE FINGER, and a promote re-rasterises at a different pixel snap — a
   visible one-pixel shift of all three slides, twice per tap, which is half of
   why selecting a photo read as glitchy (js/viewer.js's SLOP is the other half).
   Declared here so the layer exists from the first frame and for exactly as long
   as the viewer is displayed, with nothing changing it in between. */
.vw-track { position:absolute; inset:0; display:flex; transform:translate3d(0,0,0); }
/* The settle. Removed again the moment it lands, so the recycle that follows —
   rotating a node and re-centring the track — is invisible rather than
   animated. */
.vw-track.anim { transition:transform .26s cubic-bezier(.22,.61,.36,1); }
.vw-slide { position:relative; flex:0 0 100%; width:100%; height:100%; }
/* -webkit-user-drag is not decoration. A press-and-move on an <img> starts the
   platform's own image drag, which fires pointercancel and abandons the swipe
   half way — reproduced in a desktop browser, where it turned roughly every
   other page turn into a spring-back. iOS answers the same gesture with a
   share sheet over the photo. The select rules stop the long-press callout for
   the same reason. */
.vw-img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain;
          transform-origin:center; -webkit-user-drag:none; -webkit-touch-callout:none;
          -webkit-user-select:none; user-select:none; }
.vw-img.snap { transition:transform .2s ease-out; }
.vw-top { position:absolute; top:0; left:0; right:0; z-index:2; display:flex; align-items:center; gap:10px;
          padding:calc(env(safe-area-inset-top) + 6px) 10px 10px; background:linear-gradient(#000b,#0000); }
/* Translucent WHITE, not translucent black. object-fit:contain letterboxes the
   photo against #000, so a black scrim is invisible exactly where these two
   controls sit — the ✕ and Delete read as bare floating text with nothing to
   aim at. White at low alpha reads as a control over a dark letterbox and over
   a bright photo alike. */
.vw-x { width:44px; height:44px; flex:0 0 44px; margin:0; padding:0; border-radius:50%;
        background:#ffffff26; color:#fff; font-size:16px; font-weight:400;
        backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px); }
.vw-count { font-size:14px; font-weight:700; color:#fff; font-variant-numeric:tabular-nums;
            text-shadow:0 1px 3px #000c; }
.vw-del { position:absolute; left:50%; bottom:calc(env(safe-area-inset-bottom) + 16px); transform:translateX(-50%);
          z-index:2; width:auto; margin:0; padding:12px 22px; min-height:44px; border-radius:99px;
          background:#ffffff1f; color:#fca5a5; font-size:15px; font-weight:700;
          backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px); }
/* ---------- WAITING TO SEND ----------
   Nothing in this list has reached Shop-Ware. The transcript is the point of a
   row and so it is set at reading size in full ink, not as the muted caption a
   status list would give it: it is how a technician confirms the AI cleanup did
   not put words in their mouth, and it is what makes sorting the finding later
   possible without remembering which photo was which. */
.wh { display:flex; align-items:center; gap:8px; margin:0 2px 4px; }
.wh .t { font-size:15px; font-weight:800; }
.pill { font-size:11px; font-weight:800; padding:2px 9px; border-radius:99px; background:var(--warn); color:#3a2c00; }
.wsub { font-size:11.5px; color:var(--mut); margin:0 2px 12px; }
.uf { background:#131d31; border:1px solid #2c3a5c; border-radius:14px; padding:12px; margin-bottom:10px; }
.uf.bad { border-color:#5a2b2b; }
.uf .l1 { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:9px; }
.uf .thumbs { display:flex; gap:5px; }
/* The tile opens the photo full-size (js/viewer.js). It is a real button now
   rather than a decorative <img>, because a technician confirming they shot the
   right thing before it goes on a customer's repair order is the entire reason
   the picture is on this card. No visible chrome: the photo IS the affordance,
   the same way the capture card's tiles work. */
.uf .thumbs .th { width:38px; height:38px; margin:0; padding:0; border-radius:8px; overflow:hidden;
                  background:#0d1526; -webkit-tap-highlight-color:transparent;
                  transition:opacity var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); }
.uf .thumbs .th:active { opacity:.6; }
.uf .thumbs img { width:100%; height:100%; border-radius:8px; display:block; object-fit:cover; background:#0d1526; }
/* WHERE THE "NOT SENT YET" PILL WAS. That pill restated the section title, the
   count badge and the line under them, on every row — four sayings of one fact,
   in the only corner of a card with room for a control. This is the control.
   44px of target around a 19px glyph, the same arithmetic as ⟳ in the header,
   because it is tapped with a glove on and a mis-tap here is a lost finding.
   Muted at rest rather than red: it is not a warning, it is a way out, and a
   red icon on every card would compete with the error states that matter. */
.uf-del { width:44px; flex:0 0 44px; height:44px; margin:-4px -4px -4px 0; padding:0; border-radius:12px;
          background:transparent; color:var(--mut); display:flex; align-items:center; justify-content:center;
          touch-action:manipulation; -webkit-tap-highlight-color:transparent;
          transition:background var(--dur-1) var(--ease), color var(--dur-1) var(--ease),
                     transform var(--dur-1) var(--ease); }
.uf-del:active { background:#3a2222; color:#fca5a5; }
/* "This one is for another car." Amber rather than red — it is not an error,
   the finding is fine and still sendable — and small enough not to compete with
   the transcript, which is what the row exists to show. */
.uf .owo { font-size:11px; font-weight:800; letter-spacing:.04em; color:#fde047; margin:0 0 7px; }
.uf .tx { font-size:13.5px; line-height:1.45; color:var(--ink); }
.uf .tx.wait { color:var(--mut); font-style:italic; }
/* The same note, editable — only while a finding is held. It overrides the
   global textarea min-height because these boxes are sized by their content
   (autoGrow in js/jobs.js) rather than by a floor.

   16px IS THE iOS NO-ZOOM SIZE AND THAT IS WHY IT IS 16px (2026-08-07). It was
   13.5px to match the card's read-only type, with a comment here arguing that
   matching the type mattered more than the zoom because "the tech taps it
   deliberately". That was wrong in the field: Safari zooms the whole viewport
   on focus for any field under 16px, so every tap on a note threw the page out
   of position and the technician had to pinch back before they could read what
   they were correcting. The capture card's own note box never did this — it
   inherits the global 16px — so the two boxes behaved differently for no reason
   a tech could see. They match now, and the cost is only that a held note reads
   slightly larger than a sent one, which is the right way round: the held one
   is the one still being read and changed. */
/* IT HAS TO READ AS A FIELD. Once the note renders at its true height (the
   sizing bug is in js/jobs.js, not here) it is a paragraph of the technician's
   own words at reading size in full ink — which looks exactly like the quoted,
   read-only text a sent finding shows, and nobody taps a quote. The border is
   one step brighter than the card's own hairline and the pencil sits in the
   corner: between them the box is legibly an input from arm's length, without
   turning a note into a form control. The pencil is a MARK, not a button —
   pointer-events off, so the whole area still belongs to the textarea. */
.uf .tx-wrap { position:relative; }
.uf .tx-edit { display:block; font-size:16px; line-height:1.4; color:var(--ink); font-family:inherit;
               background:#0d1526; border:1px solid #3a486a; border-radius:10px;
               padding:9px 26px 9px 10px; resize:none; overflow:hidden; min-height:0; }
.uf .tx-edit:focus { border-color:var(--acc); outline:none; }
.uf .tx-wrap .pen { position:absolute; top:7px; right:7px; display:flex; color:var(--mut);
                    opacity:.6; pointer-events:none; }
.uf .tx-edit:focus + .pen { color:var(--acc); opacity:1; }
/* BOTH LISTS. This said `.uf .er` only, and a sent row has carried the same
   class since the two lists existed — so the one message on that side that
   matters, "the shop desktop could not finish this one, go and look at the
   repair order", rendered at the body's 16px in full white ink with no red
   anywhere on it. It read as a sentence of the finding rather than as the
   warning it is. Found by looking at the rendered list rather than at this
   file, which is the only way it could have been found. */
.uf .er, .se .er { font-size:12px; color:#fca5a5; margin-top:7px; line-height:1.4; }
/* THE SEND THAT DIDN'T GO. On shop wi-fi this happens, and until 2026-08-07 the
   card said so in the same quiet red as an edit rejection and offered nothing
   to tap — so the technician walked away from the car believing it had landed.
   Filled rather than outlined, and full width: every other control on this card
   is a hairline on transparent, so a solid block is unmissable against them
   without a colour this palette does not have. It is the retry, not a label. */
.senderr { width:100%; margin-top:10px; padding:12px; border:1px solid #7f1d1d; border-radius:11px;
           background:#3a2222; color:#fca5a5; font-size:14px; font-weight:700; }
.senderr:active { background:#4a2a2a; }
.act { display:flex; gap:7px; margin-top:11px; }
.act .full { flex:1; margin:0; padding:12px 11px; border:1px solid #35446a; border-radius:11px; background:transparent;
             color:var(--ink); font-size:14.5px; font-weight:700;
             display:flex; align-items:center; justify-content:center; gap:9px; }
.uf .retry { width:auto; margin-top:10px; padding:8px 15px; border-radius:9px; background:#273350;
             color:#fca5a5; font-size:12.5px; font-weight:700; }
.allbtn { width:100%; margin-top:2px; padding:12px; border:1px solid #35446a; border-radius:12px;
          background:transparent; color:var(--mut); font-size:13.5px; font-weight:700; }
/* .calm went with the "take as many as you like" line (2026-08-07) — it was the
   third piece of reassurance copy in one viewport, and the feature explains it
   the first time anybody uses it. */
/* ---------- SENT ----------
   Still no status chips and no timestamps — this list answers "what did I say,
   and where did it land", and both of those compete with it.

   THUMBNAILS ARE BACK (2026-08-07), and this line used to forbid them. The old
   reasoning was right about ornament and wrong about which column was the
   ornament: the row was three stacked blocks — an uppercase destination
   eyebrow, the note, then "1 photo" — so a one-line finding cost three lines,
   and the widest, loudest element on it was the destination. It is two columns
   now. The note gets the whole left side at reading size, and the photo plus
   where it went share a narrow right one at a weight that recedes. The picture
   is not decoration here: it is the fastest way a technician recognises which
   finding a row is, faster than reading the first four words back. The memory
   cost is bounded to one tile per row by js/jobs.js, which is where that
   argument is written out. */
/* THE HEADER IS THE CONTROL for the whole section, and until now the only thing
   that said so was the chevron rotating after the fact. It gets a tint under the
   thumb instead — not a transform, because the row it is in does not move, its
   contents do. The negative margins are what let an 8px bleed of tint exist
   inside a card whose padding is already spent: they cancel the padding added on
   the same line, so nothing above or below it shifts by a pixel. */
.sh { display:flex; align-items:center; justify-content:space-between; gap:10px; cursor:pointer;
      margin:-6px -8px; padding:6px 8px; border-radius:10px;
      -webkit-tap-highlight-color:transparent; transition:background var(--dur-1) var(--ease); }
.sh:active { background:#243352; }
.sh .t { font-size:15px; font-weight:800; }
.sh .n { font-size:11px; color:var(--mut); font-variant-numeric:tabular-nums; display:inline-block; }
.sh .cv { color:var(--mut); font-size:13px; transition:transform var(--dur-2) var(--ease); }
.sh.closed .cv { transform:rotate(-90deg); }
/* NO TRANSITION DECLARED HERE, on purpose. The collapse is animated from
   js/jobs.js's tweenSent(), in inline styles, because it has to measure a
   height first and because it must run ONLY on a tap: renderSent() rewrites
   this element every 2.5s off the poller, and a transition living on the class
   would replay the section's own opening at a technician reading it. .hide is
   the resting state and stays a plain display flip. */
.sbody { margin-top:4px; }
.sbody.hide { display:none; }
.se { border-top:1px solid #2a3653; padding:12px 2px 13px; }
.se:first-child { border-top:0; }
.se-row { display:flex; gap:12px; }
/* THE NOTE IS A BUTTON, so it starts by giving back everything the global
   button rule hands it — that rule is full-width, padded, filled and bold,
   which is right for a form card and wrong for a paragraph of somebody's own
   words. Same overriding job as .lrow in the sheet.
   Clamped to two lines: enough to recognise a finding, and .open is what a tap
   gives you when it is not enough to re-read one (js/jobs.js holds which rows
   are open, in state, because this list repaints every 2.5s). */
.se .tx { flex:1; min-width:0; margin:0; padding:0; border:0; border-radius:0; display:block;
          background:transparent; color:var(--ink); font:inherit; font-size:13.5px;
          line-height:1.45; font-weight:400; text-align:left;
          -webkit-tap-highlight-color:transparent; }
/* THE CLAMP IS ON THE SPAN, NOT ON THE BUTTON AROUND IT, and it shipped the
   other way round for one round of this work. Chrome computes a <button> to
   `display:flow-root` whatever this file asks for, so -webkit-line-clamp — which
   only does anything inside a -webkit-box — was set, inherited, and ignored:
   every note rendered at full length and the row was four lines tall, which is
   exactly the shape the two columns were meant to fix. A plain child span takes
   the -webkit-box without argument. */
.se .tx .tt { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; }
.se .tx.open .tt { display:block; -webkit-line-clamp:none; overflow:visible; }
/* A note that already fits has nothing to open, and js/jobs.js measures which
   ones those are after the append. Taking the target away is the honest
   version: a control that answers with nothing is worse than no control. */
.se .tx.flat { pointer-events:none; }
/* 92px, which is 44 of thumbnail and the rest for a checklist item name to wrap
   into. Right-aligned as a column so the tile and the label share an edge —
   ragged left is what makes this read as a margin note rather than as a second
   content column competing with the note. */
.se-side { flex:0 0 92px; display:flex; flex-direction:column; align-items:flex-end; gap:5px; }
.se-side .th { position:relative; width:44px; height:44px; margin:0; padding:0; border-radius:9px;
               overflow:hidden; background:#0d1526; -webkit-tap-highlight-color:transparent; }
.se-side .th img { width:100%; height:100%; object-fit:cover; display:block; }
/* The "3 photos" line, in the corner of the tile it is about. */
.se-side .th .more { position:absolute; right:2px; bottom:2px; min-width:16px; padding:0 3px;
                     border-radius:5px; background:rgba(3,6,13,.72); color:#fff;
                     font-size:9.5px; font-weight:800; line-height:14px; text-align:center; }
/* MUTED FOR EVERY STATE, and the green went with the eyebrow. It was three
   colours saying which status the finding was in — green published, blue
   sending, grey unpaired — on the element the eye lands on first, in a list
   where the status is not the question. "sending…" is its own full-width line
   under the row now (js/jobs.js), which is where an exception belongs. */
.se .on { font-size:10px; font-weight:800; letter-spacing:.04em; text-transform:uppercase;
          color:var(--mut); text-align:right; line-height:1.3; }
.se .meta { font-size:11px; color:var(--mut); margin-top:7px; }
/* One line, and .s-note is gone with the paragraph it held — see renderSent()
   in js/jobs.js for what both used to say and why that was a real loss worth
   taking. The padding came down with the word count. */
.s-empty { font-size:12px; color:var(--mut); padding:4px 2px 8px; line-height:1.5; }
/* ---------- THE SHEET (js/sheet.js) ---------- */
/* MATCHED TO THE SHEET, not merely close to it. The dim ran at .18s against the
   sheet's .22s, so the screen finished darkening about forty milliseconds before
   the thing it was darkening for arrived — small enough that nobody names it and
   large enough that opening the sheet read as two events. */
.back { position:fixed; inset:0; background:rgba(3,6,13,.72); opacity:0; pointer-events:none;
        transition:opacity var(--dur-3) var(--ease); z-index:40; }
.back.on { opacity:1; pointer-events:auto; }
/* dvh with a vh fallback: the sheet is the one surface in the document flow
   with a viewport-relative height, and 88vh on iOS measures against the LARGE
   viewport — the one with the URL bar retracted — so the bottom of the sheet
   sat under the bar whenever it was not. The safe-area padding is the other
   half: Confirm is the sticky bottom control of the whole app, and a flat 22px
   put it under the home indicator on every notched phone. Older engines take
   the first declaration and are no worse off than before. */
.sheet { position:fixed; left:0; right:0; bottom:0; z-index:50; max-width:480px; margin:0 auto;
         background:var(--card); border-radius:20px 20px 0 0;
         padding:14px 16px calc(env(safe-area-inset-bottom) + 22px);
         transform:translateY(102%); transition:transform var(--dur-3) var(--ease);
         max-height:88vh; max-height:88dvh; display:flex; flex-direction:column; }
.sheet.on { transform:none; }
/* THE DRAG HANDLE, and since 2026-08-07 it is one. It always looked like the
   grab bar the shape of a sheet leads you to expect, and it always only
   answered taps — so everyone who has used a phone tried to drag it first,
   nothing moved, and the sheet read as stuck. js/sheet.js now drives it, plus
   the title strip beside it. The padding is what makes a 4px bar a 44px tap
   target; touch-action:none is what lets pointermove see the drag at all
   instead of the browser claiming it for a page scroll. */
.grab { display:block; width:60px; height:24px; padding:10px 11px; margin:0 auto 8px; border:0;
        border-radius:9px; background:transparent; touch-action:none; }
.grab::after { content:''; display:block; width:38px; height:4px; border-radius:9px; background:#3a486a; }
.sh-t { font-size:16px; font-weight:800; margin-bottom:10px; touch-action:none; }
/* Only the list scrolls, so "Send to the repair order" stays on screen however
   long the checklist is and however far down the tech has scrolled. */
.sh-list { overflow-y:auto; overscroll-behavior:contain; border:1px solid #33405e; border-radius:12px;
           background:#0d1526; padding:0 8px; flex:1; min-height:130px; }
.lrow { display:flex; align-items:center; justify-content:space-between; gap:10px; width:100%;
        min-height:46px; margin:0; padding:10px 4px;
        background:none; border:0; border-top:1px solid #2a3653; border-radius:0; color:var(--ink);
        font:inherit; font-size:14px; font-weight:500; text-align:left; line-height:1.3; }
.lrow:first-child { border-top:0; }
/* THE SELECTED DESTINATION. A fill AND a checkmark, never just a border: this
   is read in a bay, in bad light, through a screen with a thumbprint on it, and
   a 1px outline is not an answer to "which one did I pick" — it is a question.
   The row's own text carries the third signal by going heavier, which is what a
   leading accent bar used to do (removed 2026-08-07 — it read as an alert
   stripe down the side of the list rather than as a selection). Same treatment
   on the repair-order option below, because that option is not special: it
   selects and waits for Confirm like any row. */
/* The fill and the checkmark ARRIVE rather than appear. 140ms is under the
   threshold where a delay is felt as lag and over the one where a change is
   missed entirely — which matters here more than anywhere else in the app,
   because this is the moment a technician learns their tap chose a destination
   and not the one above it. */
.lrow, .sh-none { transition:background var(--dur-1) var(--ease),
                             border-color var(--dur-1) var(--ease); }
.lrow.on, .sh-none.on { background:#3b82f622; color:var(--ink); }
.lrow.on .lt, .sh-none.on .lt { font-weight:700; }
.lrow .lt, .sh-none .lt { min-width:0; }
.ck { flex:0 0 auto; color:var(--acc); font-size:15px; font-weight:800; opacity:0;
      transition:opacity var(--dur-1) var(--ease); }
.lrow.on .ck, .sh-none.on .ck { opacity:1; }
/* Pinned outside the scroller so it is in the same place whatever the checklist
   did — no items, a failed load, another car's list. It is an OPTION now, not a
   send button, so it is styled as a row that happens to live outside the list
   rather than as an action. */
.sh-none { display:flex; align-items:center; justify-content:space-between; gap:10px;
           width:100%; margin-top:10px; padding:13px; border:1px solid #35446a; border-radius:12px;
           background:transparent; color:var(--mut); font-size:14px; font-weight:700; text-align:left; }
.sh-none.on { border-color:var(--acc); }
/* THE COMMIT, and the only control in this sheet that reaches Shop-Ware. Dead
   until something is selected — and it says "Pick where it goes" while it is,
   because a greyed button with a live label reads as broken rather than as
   waiting for an answer. */
.sh-go { width:100%; margin-top:12px; padding:14px; border-radius:12px;
         background:var(--acc); color:#fff; font-size:15.5px; font-weight:800; }
.sh-go:disabled { background:#273350; color:var(--mut); }
/* Service headings, shown only when a repair order carries more than one
   service to tell apart (js/inspections.js). */
.insp-group { padding:8px 4px 4px; border-top:1px solid #2a3653; font-size:11px; font-weight:700;
              letter-spacing:.04em; text-transform:uppercase; color:var(--mut); }
.insp-group:first-child { border-top:0; }
.sh-empty { font-size:12.5px; color:var(--mut); padding:14px 6px; line-height:1.5; }
/* ---------- THE UNDO TOAST (js/toast.js) ----------
   The only transient message in the app. Everything else here is inline and
   persistent because it describes a state that is still true; this describes an
   act that has just happened, and it stops being true on its own after five
   seconds. It sits above the sticky work-order bar (30) and below the sheet
   (40/50) — a delete is not reachable while the sheet is up, and if the two ever
   did overlap the sheet is the one being used.

   Undo is the whole point of the element, so it gets the accent and a 44px
   target while the message beside it is plain. pointer-events go off with the
   opacity or an invisible bar swallows taps on the last card in the list. */
.toast { position:fixed; left:12px; right:12px; bottom:calc(env(safe-area-inset-bottom) + 12px);
         z-index:35; max-width:456px; margin:0 auto; display:flex; align-items:center; gap:10px;
         background:#243352; border:1px solid #35446a; border-radius:12px; padding:6px 6px 6px 14px;
         box-shadow:0 10px 28px rgba(3,6,13,.55);
         opacity:0; transform:translateY(8px); pointer-events:none;
         transition:opacity .16s var(--ease), transform .16s var(--ease); }
.toast.on { opacity:1; transform:none; pointer-events:auto; }
.toast-msg { flex:1; min-width:0; font-size:13.5px; font-weight:600; color:var(--ink); }
.toast-undo { width:auto; flex:0 0 auto; margin:0; padding:10px 16px; min-height:44px; border-radius:9px;
              background:transparent; color:#93c5fd; font-size:14.5px; font-weight:800; }
.toast-undo:active { background:#2f3d61; }
.chip { display:inline-block; font-size:12px; font-weight:700; border-radius:99px; padding:2px 10px; margin-left:8px; vertical-align:middle; }
/* Waiting-list chips are smaller and carry no left margin — they sit at the end
   of a flex row rather than after a line of text the way the admin table's do. */
.uf .chip { font-size:10px; font-weight:800; padding:3px 9px; margin-left:0; white-space:nowrap; }
.c-work { background:#3b82f622; color:#93c5fd; }
.c-ready { background:#eab30822; color:#fde047; }
/* .c-hold went with the waiting list's "Not sent yet" pill (2026-08-07) — its
   only user. The owner page still labels a held job, and it uses .c-ready. */
.c-pub { background:#22c55e22; color:#86efac; }
.c-fail { background:#ef444422; color:#fca5a5; }
#timer { font-variant-numeric:tabular-nums; }
/* Owner admin (admin.html). The rest of this sheet is phone-first because a
   technician holds a phone; the owner reads this one screen at a desk, so it
   gets a wider column and real tables. They still scroll sideways rather than
   forcing the page to, so the layout survives a phone. */
.wrap.wide { max-width:760px; }
.scroll { overflow-x:auto; }
table.tbl { width:100%; border-collapse:collapse; font-size:13px; margin-top:10px; }
.tbl th { text-align:left; font-size:12px; font-weight:600; color:var(--mut); white-space:nowrap; padding:0 10px 6px 0; border-bottom:1px solid #2a3653; }
.tbl td { padding:9px 10px 9px 0; border-bottom:1px solid #2a3653; vertical-align:top; }
.tbl tr:last-child td { border-bottom:0; }
.tbl .who { font-weight:700; }
.tbl .chip { margin-left:0; }
.tbl .hint { margin-top:3px; }
button.mini { width:auto; margin:6px 0 0; padding:4px 10px; border-radius:8px; background:#273350; color:var(--mut); font-size:12px; font-weight:600; }
/* ---------- ARRIVING ----------
   THE REVEAL. js/auth.js puts .page-in on .wrap when enterApp() lets a
   technician in, and takes it off 600ms later so nothing can replay it. The
   four surfaces are read top to bottom, so they arrive in that order 40ms
   apart; landing together reads as a page that was always there, which is
   exactly the reading the boot spinner has just spent a second contradicting.

   #woBar and #captureCard are usually display:none at the moment the class goes
   on — wo-detect.js decides which capture card belongs on screen a network
   round trip later. That works out rather than being a hole: an animation on a
   display:none element does not run, and RESTARTS from the beginning when
   display comes back, so a card revealed inside the window still arrives
   properly. A card revealed after it simply appears, and that is the right
   trade — a shop network slow enough to miss 600ms has already made the
   technician wait, and a fade landing a second late reads as a stall. */
.page-in #woBar { animation:pagein var(--dur-3) var(--ease) both; }
.page-in #captureCard, .page-in #noRoCard { animation:pagein var(--dur-3) var(--ease) 40ms both; }
.page-in #waitingWrap { animation:pagein var(--dur-3) var(--ease) 80ms both; }
.page-in #sentCard { animation:pagein var(--dur-3) var(--ease) 120ms both; }
@keyframes pagein { from { opacity:0; transform:translateY(8px); } }
/* A ROW ARRIVES EXACTLY ONCE, and that cannot be said in CSS alone. render()
   rebuilds both lists whole every 2.5s off the poller, so this animation would
   re-fire on every rebuild and the list would strobe for as long as anything is
   in flight — worse than no animation at all. js/jobs.js's `painted` set is
   what hands `in` only to a row that is genuinely new; read the comment there
   before changing either half. */
.uf.in, .se.in { animation:rowin var(--dur-2) var(--ease) both; }
@keyframes rowin { from { opacity:0; transform:translateY(6px); } }
/* Leaving, and only on a delete — see deleteFinding() in js/jobs.js for why a
   row leaving on SEND deliberately gets nothing. */
.uf.out { animation:rowout .16s var(--ease) both; }
@keyframes rowout { to { opacity:0; } }
/* The Sent count, when it changes. Not when it repaints — js/jobs.js knows the
   difference and this class is only ever added on the change. */
.sh .n.pop { animation:npop var(--dur-1) var(--ease); }
@keyframes npop { 50% { transform:scale(1.35); } }
/* ---------- REDUCED MOTION ----------
   ONE BLOCK, at the end, so everything the setting turns off is readable in one
   place rather than scattered through the file as a dozen little media queries.
   The precedent is the record button's pulse near the top, which has its own
   because it predates this block; it is left where it is because it also has to
   substitute a resting opacity, which is not something this block does.

   IT KILLS MOVEMENT AND LEAVES COLOUR ALONE, deliberately. Every :active tint
   in this file stays: a surface darkening under a thumb is not motion, it is
   the only proof a gloved tap on a phone in a bay actually landed, and taking
   it away in the name of accessibility would leave this app less usable for the
   person who asked for less motion, not more. */
@media (prefers-reduced-motion: reduce) {
  .cap:active, .sh-go:active, #loginBtn:active, .senderr:active,
  .allbtn:active, .act .full:active,
  .uf-del:active, .toast-undo:active, .foot-btn:active,
  .uf .thumbs .th:active, .se-side .th:active,
  .cam-shutter:active, .cam-p:active { transform:none; }
  .page-in #woBar, .page-in #captureCard, .page-in #noRoCard,
  .page-in #waitingWrap, .page-in #sentCard,
  .uf.in, .se.in, .uf.out, .sh .n.pop { animation:none; }
}
