  :root{
    --bg:#0b0f16;--bg2:#10151d;--card:#151c26;--card2:#1c2531;
    --line:#242d39;--line2:#3a4757;--fg:#eef2f7;--mut:#94a3b5;
    --ok:#33cd92;--warn:#f6a609;--acc:#5b91ff;--acc2:#3d78f0;--danger:#ff6b6b;
    --acc-weak:rgba(91,145,255,.13);--acc-line:rgba(91,145,255,.42);--ok-weak:rgba(49,196,141,.14);--warn-weak:rgba(246,166,9,.14);
    --r:12px;--r-lg:16px;--r-sm:10px;--shadow:0 12px 40px rgba(0,0,0,.45);
    --sat:env(safe-area-inset-top,0px);--sab:env(safe-area-inset-bottom,0px)
  }
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  html{-webkit-text-size-adjust:100%;text-size-adjust:100%}   /* iOS must not rescale text in landscape */
  body{margin:0;font:15px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;background:var(--bg);color:var(--fg);-webkit-font-smoothing:antialiased;
       overflow-x:hidden;overscroll-behavior-y:contain}
  /* touch-action:manipulation removes the 300ms double-tap-zoom delay AND stops a double tap on
     the shutter zooming the page. user-select:none stops a long press selecting a button's label
     and raising the copy/paste bar over the camera. */
  button{font:inherit;display:inline-flex;align-items:center;justify-content:center;gap:7px;border:1px solid var(--line);background:var(--card);color:var(--fg);padding:9px 12px;border-radius:var(--r);cursor:pointer;min-height:44px;touch-action:manipulation;user-select:none;-webkit-user-select:none;transition:transform .06s ease,background .15s ease,border-color .15s ease}
  button:active{transform:scale(.97)}
  .ic{width:16px;height:16px;flex:none;display:block}
  button.p{background:linear-gradient(180deg,var(--acc),var(--acc2));border-color:var(--acc2);color:#fff;font-weight:600;box-shadow:0 2px 12px rgba(91,145,255,.22)}
  input:focus,select:focus{outline:none;border-color:var(--acc);box-shadow:0 0 0 3px rgba(91,145,255,.16)}
  .hicon{min-height:44px;min-width:44px;padding:8px 10px;font-size:15px;line-height:1}
  .camicon{position:absolute;top:10px;right:10px;z-index:6;min-height:0;width:44px;height:44px;padding:0;font-size:17px;border-radius:50%;background:rgba(0,0,0,.5);border:1px solid rgba(255,255,255,.15);backdrop-filter:blur(4px)}
  .camicon.stack2{top:62px}                       /* macro button, stacked under the torch */
  .camicon.stack3{top:114px}                      /* lens picker, stacked under those */
  .camicon.stack4{top:166px}                      /* native-camera fallback */
  /* Native-camera crop. Sits INSIDE .stage so the ROI fractions map identically to the live
     view — same box, same crop math, same instruction to the operator. */
  .ncrop{position:absolute;inset:0;z-index:10;background:#000;overflow:hidden;touch-action:none}
  .ncrop canvas{position:absolute;inset:0;width:100%;height:100%}
  .ncbar{position:absolute;left:0;right:0;bottom:0;z-index:2;display:flex;gap:6px;align-items:center;
         padding:8px;background:linear-gradient(transparent,rgba(0,0,0,.75) 40%)}
  .ncbar button{min-height:44px;padding:8px 12px;font-size:12px}
  .ncbar .px{flex:1;font-size:11px;color:rgba(255,255,255,.85);line-height:1.3}
  .ncbar .px b{display:block;font-size:10px;font-weight:500;color:rgba(255,255,255,.6)}
  .ncbar .px.warn{color:var(--warn)}
  /* Lens picker. Thumbnails, not names — no metadata on the web platform tells you which of a
     phone's back cameras is the main one (no focal length, no FOV, and Android's own docs say
     not to trust id order), so the only honest discriminator is looking through each. */
  .lenspick{position:absolute;inset:0;z-index:9;background:rgba(0,0,0,.82);backdrop-filter:blur(3px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:14px;text-align:center}
  .lenspick h4{margin:0;font-size:14px;color:#fff}
  .lenspick .sub{margin:0;font-size:11.5px;color:rgba(255,255,255,.72);max-width:280px;line-height:1.45}
  .lensrow{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
  .lenscard{width:96px;padding:0;border-radius:10px;overflow:hidden;background:#111;border:2px solid rgba(255,255,255,.18);min-height:0}
  .lenscard.on{border-color:var(--acc)}
  .lenscard img{display:block;width:100%;height:72px;object-fit:cover;background:#222}
  .lenscard span{display:block;font-size:10px;padding:3px 2px;color:rgba(255,255,255,.8);line-height:1.3}
  /* zoom segmented control (only shown when the lens exposes a zoom capability) */
  .zoombar{position:absolute;right:10px;bottom:12px;z-index:6;display:flex;gap:3px;background:rgba(0,0,0,.5);border:1px solid rgba(255,255,255,.15);border-radius:20px;padding:3px;backdrop-filter:blur(4px)}
  .zoombar button{min-height:0;width:42px;height:44px;padding:0;font-size:12px;font-weight:700;border-radius:16px;background:transparent;border:0;color:#fff}
  .zoombar button.on{background:var(--acc)}
  /* tap-to-focus ring pulse */
  .focusring{position:absolute;width:66px;height:66px;margin:-33px 0 0 -33px;border:2px solid var(--acc);border-radius:50%;pointer-events:none;z-index:7;opacity:0}
  .focusring.show{animation:focuspulse .6s ease-out}
  @keyframes focuspulse{0%{opacity:1;transform:scale(1.4)}70%{opacity:1}100%{opacity:0;transform:scale(1)}}
  #torch.on{background:var(--acc);border-color:var(--acc)}
  .grow{flex:1}.hidden{display:none}
  .chip{font-size:12px;color:var(--mut);border:1px solid var(--line);border-radius:20px;padding:5px 11px;white-space:nowrap}
  /* header */
  header{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:10px;padding:calc(10px + var(--sat)) 14px 10px;border-bottom:1px solid var(--line);background:rgba(10,14,19,.82);backdrop-filter:saturate(150%) blur(12px);-webkit-backdrop-filter:saturate(150%) blur(12px)}
  header b{font-size:16px;letter-spacing:.2px}
  .hdr-actions{display:flex;gap:8px}
  .hdr-actions button{min-height:44px;padding:9px 12px;font-size:13px}
  /* narrow phones: keep the header on one tidy line, trim side padding */
  @media(max-width:440px){
    header{gap:8px;padding-left:12px;padding-right:12px}
    header .chip{display:none}          /* usage still enforced server-side; shown ≥440px */
    /* 🐛 THE HEADER OVERFLOWED THE PHONE BY 155px AT 320px WIDE. Five labelled buttons do not
       fit, so Guide and Sign out simply ran off the right edge — present in the DOM, painted
       outside the viewport, unreachable. (body{overflow-x:hidden} hides the scrollbar; it does
       not bring the buttons back.) The icons are already there via data-ic, so below 440px the
       LABEL is what goes: font-size:0 collapses the text node without needing a wrapper span
       around each one, and aria-label carries the name that the text used to. */
    .hdr-actions{gap:4px}
    .hdr-actions button{font-size:0;padding:9px;min-width:44px}
    .hdr-actions button .ic{width:19px;height:19px}
    /* The brand yields before the controls do: an ellipsised title is a cosmetic loss, a
       Sign-out button pushed off the right edge is a functional one. */
    header>b{font-size:14px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  }
  @media(max-width:365px){
    .hdr-actions{gap:2px}
    .hdr-actions button{padding:9px 7px;min-width:40px}
    /* No child of a flex row may force the page wider than the phone. */
  .row,.wrow,.dgrid,.tabs,.controls2{min-width:0}
  .row>*,.wrow>*{min-width:0}
  .wrap{padding-left:12px;padding-right:12px}
    .actionbar{padding-left:12px;padding-right:12px}
  }
  /* layout: single column (mobile) with room for the fixed action bar; two columns on desktop */
  /* ⚠️ THE BOTTOM RESERVATION IS MEASURED AT RUNTIME, NOT HARDCODED. It was `158px`, chosen
     when the fixed bar held the controls row and the shutter. The always-on type box was added
     underneath on 2026-08-12 and the number was not moved, so the bar is ~185px and the last
     rows of the Captured list sat UNDERNEATH it on every phone. --abh is set from the bar's own
     height by fitActionBar(), so adding a control can never hide the list again. */
  .wrap{max-width:520px;margin:0 auto;padding:14px 14px calc(var(--abh, 158px) + 16px + var(--sab))}
  .col-cam,.col-list{min-width:0}
  @media(min-width:900px){
    .wrap{max-width:1040px;display:grid;grid-template-columns:minmax(0,440px) minmax(0,1fr);gap:30px;align-items:start;padding:20px 24px}
    /* capture panel is viewport-fit so the camera + controls + Scan are ALWAYS above the fold; only the list scrolls */
    .col-cam{position:sticky;top:74px}
  }
  /* login */
  #login{max-width:370px;margin:14vh auto;padding:26px;background:var(--card);border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:var(--shadow)}
  #login h1{margin:0 0 4px;font-size:22px}#login p{margin:0 0 18px;color:var(--mut);font-size:13px}
  /* ⚠️ 16px IS A HARD FLOOR ON EVERY INPUT — IT IS NOT A TYPOGRAPHY CHOICE.
     iOS Safari zooms the whole page in whenever a focused input has a font-size below 16px, and
     it does NOT zoom back out on blur. `font:inherit` meant every field inherited the 15px body
     size, so tapping the weight box shoved the layout to a new scale and left it there. That is
     the "it changes dimensions" the shop reported, and it had nothing to do with pinch. */
  input{width:100%;padding:12px;margin:6px 0;background:var(--bg2);border:1px solid var(--line);border-radius:var(--r);color:var(--fg);font-family:inherit;font-size:16px;min-height:48px}
  input:focus{outline:none;border-color:var(--acc)}
  .err{color:var(--danger);font-size:13px;min-height:18px}
  /* camera */
  /* mobile: camera fills the space between the sticky header and the fixed action bar, so the ROI
     and the Scan button are ALWAYS both visible without scrolling (no hiding behind the bar) */
  /* ⚠️ THE STAGE HEIGHT MUST NOT COME FROM A UNIT THAT MOVES.
     This was `100dvh`, the DYNAMIC viewport — it grows and shrinks as the URL bar hides and
     shows, and tracks the visual viewport on pinch. Everything about capture is a PERCENTAGE
     OF THIS BOX: the ROI guide the operator frames against is `left:27%;width:46%`, and
     roiRect() reads clientWidth/clientHeight at capture time to map that box onto source
     pixels. A height that drifts under the operator is a guide box that drifts, and a crop
     region that is not quite what they aimed at. `svh` is the smallest viewport — it is
     defined with the browser chrome shown and so does NOT move while scrolling.
     touch-action:none makes pinch INERT here — see the note above initPinchZoom's grave in
     the JS: the app deliberately has no pinch gesture, and the browser must not scale the
     document under the guide box either. The rest of the page stays pinchable. */
  .stage{position:relative;border-radius:var(--r-lg);overflow:hidden;background:#000;box-shadow:var(--shadow);height:calc(100vh - 292px);height:calc(100svh - 292px);min-height:240px;max-height:600px;touch-action:none}
  /* phone: Scan ⇄ Type ⇄ List view toggle — camera-dominant while scanning, the type pass with
     no camera at all, full-screen list to review */
  .viewswitch{display:none}
  /* ⚠️ IT HAS TO EXIST ON DESKTOP TOO, and it did not until the Type view landed. Above 900px
     both columns are on screen, so the switch had nothing left to switch and was hidden
     outright — which would have made Type unreachable on a laptop, i.e. exactly the machine
     most likely to have no usable camera. Here it governs the LEFT column only, so the
     Captured button is dropped (that column never goes away at this width). */
  @media(min-width:900px){
    .viewswitch{display:flex;gap:8px;max-width:1040px;margin:0 auto;padding:16px 24px 0}
    .viewswitch #vList{display:none}
    .viewswitch button{flex:0 0 auto;min-width:132px;min-height:44px;color:var(--mut);font-weight:600}
    .viewswitch button.on{background:var(--acc);border-color:var(--acc);color:#fff}
  }
  @media(max-width:899px){
    .viewswitch{display:flex;gap:8px;position:sticky;top:calc(53px + var(--sat));z-index:24;background:var(--bg);padding:10px 14px 4px}
    /* THREE buttons since the Type view landed. flex:1 1 0 + min-width:0 so the widest label
       ("Captured 12") shrinks the row instead of pushing it past the phone — the header bug of
       2026-08-12 was exactly this, five labelled buttons painted outside a 320px screen. */
    .viewswitch button{flex:1 1 0;min-width:0;min-height:48px;color:var(--mut);font-weight:600;
      white-space:nowrap;padding-left:6px;padding-right:6px;gap:5px}
    @media(max-width:379px){
      .viewswitch button{font-size:13.5px;gap:3px}
      .viewswitch .ic{width:15px;height:15px}
    }
    .viewswitch button.on{background:var(--acc);border-color:var(--acc);color:#fff}
    #app[data-view=scan] .col-list{display:none}
    #app[data-view=type] .col-list{display:none}
    #app[data-view=list] .col-cam{display:none}
    #app[data-view=list] .actionbar{display:none}
    #app[data-view=list] .wrap{padding-bottom:24px}
    /* Nothing is fixed to the bottom in Type view, so nothing has to be reserved for. */
    #app[data-view=type] .wrap{padding-bottom:24px}
  }
  /* ---- TYPE VIEW ------------------------------------------------------------------------
     The camera and its controls come off the screen entirely; what stays is the SAME
     #manualIn from the action bar, un-fixed so it sits at the top of the flow. On a phone a
     bottom-fixed field is right when the operator is aiming a camera one-handed and wrong when
     the soft keyboard is up: iOS does not lift fixed elements above the keyboard, so the box
     they are typing into can end up underneath it. In flow it simply scrolls into view. */
  #typePane{display:none}
  #app[data-view=type] #typePane{display:block}
  #app[data-view=type] .stage,
  #app[data-view=type] .camhint,
  #app[data-view=type] .controls2,
  #app[data-view=type] #shoot{display:none}
  #app[data-view=type] .actionbar{position:static;padding:0;background:none;border-top:none;
    backdrop-filter:none;-webkit-backdrop-filter:none;margin-top:0}
  #app[data-view=type] .actionbar .inner{max-width:none}
  #app[data-view=type] .manual{margin-top:0}
  /* The one field on screen, so it gets the size of one. */
  #app[data-view=type] #manualIn{min-height:60px;font-size:26px;letter-spacing:8px;text-align:center}
  #app[data-view=type] #manualIn::placeholder{letter-spacing:0;font-size:15px}
  #app[data-view=type] #manualGo{min-height:60px}
  .tp-head{display:flex;align-items:baseline;gap:10px;margin:18px 2px 4px}
  .tp-head b{font-size:15px}
  .tp-count{color:var(--mut);font-size:12px;margin-left:auto}
  .tp-hint{color:var(--mut);font-size:12px;line-height:1.5;margin:0 2px 10px}
  /* One line per piece, newest first — the operator's own receipt. Without it a typing pass on
     a phone is six characters vanishing into a box, with the sheet one view away. */
  .tp-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);
    border-radius:var(--r);margin-bottom:6px;background:var(--card)}
  .tp-row code{font-family:ui-monospace,Menlo,monospace;font-size:17px;letter-spacing:2px;font-weight:700}
  .tp-row .tp-when{color:var(--mut);font-size:11px;margin-left:auto}
  .tp-row .tp-x{min-height:44px;min-width:44px;padding:0 10px;background:none;border:none;color:var(--mut)}
  .tp-row.dup{border-color:var(--warn)}
  .tp-row.dup .tp-note{color:var(--warn);font-size:11.5px}
  .tp-row.gone{opacity:.45}
  .tp-row.gone code{text-decoration:line-through}
  .tp-empty{color:var(--mut);font-size:12.5px;padding:10px 2px}
  /* A refusal has to land next to the box that was refused. */
  .tp-msg{margin:0 0 10px;padding:11px 13px;border-radius:var(--r);font-size:13.5px;line-height:1.45;
    background:rgba(224,73,73,.12);border:1px solid var(--danger);color:var(--fg)}
  video{width:100%;height:100%;object-fit:cover;display:block}
  /* Geometry is set from the ROI constant in JS at boot — these values only avoid a flash
     of the wrong size before that runs. Change ROI, not this. */
  /* ⚠️ THE SCRIM IS A TAX ON THE WHOLE PREVIEW — KEEP IT LIGHT (was .45, cut to .16 on 2026-07-30).
     Operators reported "the picture is clear on my phone camera but not in the app". Measured on
     their screen recording: the SAME patch of desk read 106 inside the box and 69 just outside it,
     and whole-preview mean luminance was 52.8 in the app vs 100.0 in the phone's own camera — the
     scrim accounted for essentially all of the gap. Inside the box the app was BRIGHTER than the
     native camera (p95 158 vs 134), i.e. the stream was never the problem; we were dimming it.
     It got worse at c9cd5d2, which raised the opacity AND shrank the clear window from 17.6% to
     6.4% of the stage — so 94% of what the operator looks at was being dimmed by nearly half.
     The 1px dark ring does the job the scrim was really there for: it keeps the box findable
     against bright metal without touching the rest of the frame. */
  .roi{position:absolute;left:27%;top:42%;width:46%;height:14%;border:2px solid var(--acc);border-radius:8px;box-shadow:0 0 0 1px rgba(0,0,0,.55),0 0 0 100vmax rgba(0,0,0,.16);pointer-events:none}
  .roi::after{content:"fill this with the 6 characters";position:absolute;top:-20px;left:0;font-size:11px;color:var(--acc);font-weight:600;letter-spacing:.3px;white-space:nowrap;text-shadow:0 1px 3px rgba(0,0,0,.95)}
  /* .cue is the meter asking for an ACTION rather than describing a state — larger, and it
     borrows the shutter's own colour so the eye travels from one to the other. */
  .meter.cue{font-size:14px;font-weight:700;background:rgba(91,145,255,.24);
    border:1px solid var(--acc-line);letter-spacing:.02em;color:var(--acc)}
  .meter{position:absolute;left:12px;bottom:12px;font-size:12px;background:rgba(0,0,0,.6);padding:5px 10px;border-radius:20px;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);pointer-events:none}
  .camstart{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px;font-size:15px;line-height:1.55;color:var(--fg);background:rgba(0,0,0,.82);cursor:pointer;z-index:6}
  .camstart small{color:var(--mut);font-size:12.5px}
  .camhint{font-size:12px;color:var(--mut);margin:10px 2px 0;line-height:1.5}
  /* A text button, not an <a>: it opens an in-page panel, and an href-less <a> is not
     focusable or Enter-activatable (the same fault the admin sidebar had). */
  .linkish{background:none;border:none;padding:0 0 0 2px;min-height:0;color:var(--acc);
    font-size:12px;font-weight:600;text-decoration:underline;cursor:pointer}
  /* action bar — fixed to the thumb zone on mobile, inline under the camera on desktop */
  .actionbar{position:fixed;left:0;right:0;bottom:0;z-index:20;padding:10px 14px calc(10px + var(--sab));background:linear-gradient(to top,rgba(10,14,19,.99),rgba(10,14,19,.9));border-top:1px solid var(--line);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
  .actionbar .inner{max-width:520px;margin:0 auto}
  .controls2{display:flex;gap:8px;overflow-x:auto;padding-bottom:2px;margin-bottom:9px;scrollbar-width:none}
  .controls2::-webkit-scrollbar{display:none}
  .opt{flex:0 0 auto;min-height:44px;padding:8px 14px;font-size:13px;border-radius:10px;white-space:nowrap;color:var(--mut);background:var(--card);border:1px solid var(--line)}
  .opt:hover{border-color:var(--line2)}
  .opt.on{color:var(--fg)}
  .opt .mt{color:var(--mut);font-weight:500;font-size:11px;margin-left:1px}
  /* tray / bulk running tally */
  .tray{display:flex;align-items:center;gap:18px;flex-wrap:wrap;width:100%;box-sizing:border-box;margin:0 0 10px;padding:11px 14px;border:1px solid var(--line);border-radius:var(--r);background:var(--card2)}
  .tray .tv{display:flex;flex-direction:column;line-height:1.05}
  .tray .tv b{font-size:19px;font-weight:800;letter-spacing:.2px}
  .tray .tv small{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-top:3px}
  .tray small.note{color:var(--mut);font-size:11px;text-transform:none;letter-spacing:0}
  .support{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0 4px}
  .scontact{display:inline-flex;align-items:center;gap:7px;text-decoration:none;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:10px;padding:9px 13px;font-size:13px}
  .scontact:hover{border-color:var(--line2)}
  .scontact .ic{width:16px;height:16px}
  /* Auto-scan running — calm accent "live" state (no traffic-light green) */
  #auto.on{background:var(--acc-weak);border-color:var(--acc-line);color:#aecaff;font-weight:700}
  #snd.on{color:var(--acc);border-color:var(--acc-line)}
  #shoot{width:100%;padding:16px;font-size:17px;font-weight:750;border-radius:12px;letter-spacing:.2px}
  /* Auto-scanning — stays on-brand accent, gains a breathing glow + live dot */
  #shoot.scanning{animation:breathe 1.9s ease-in-out infinite}
  #shoot:disabled{opacity:.65}
  .livedot{width:9px;height:9px;border-radius:50%;background:#fff;animation:livepulse 1.5s ease-out infinite}
  @keyframes breathe{0%,100%{box-shadow:0 2px 12px rgba(91,145,255,.30)}50%{box-shadow:0 5px 26px rgba(91,145,255,.58)}}
  @keyframes livepulse{0%{box-shadow:0 0 0 0 rgba(255,255,255,.6)}70%{box-shadow:0 0 0 9px rgba(255,255,255,0)}100%{box-shadow:0 0 0 0 rgba(255,255,255,0)}}
  @media(min-width:900px){
    .actionbar{position:static;padding:0;background:none;border-top:none;backdrop-filter:none;-webkit-backdrop-filter:none;margin-top:14px}
    .actionbar .inner{max-width:none}
    .controls2{flex-wrap:wrap;overflow:visible}
    /* Desktop: cap the camera so the WHOLE capture panel fits above the fold.
       ⚠️ `min(50vh,470px)` BUDGETED FOR CAMERA + CONTROLS + SCAN AND NOTHING ELSE, so when the
       always-on type box was added under the shutter (2026-08-12) it fell off the bottom:
       measured at a 647px-tall window it sat 31px BELOW THE FOLD. That box is the path this
       shop uses for every single code, and the rule it exists to serve is "never make the
       operator wait to find out whether they'll have to type" — which it cannot do off-screen.
       The subtrahend is the rest of the column (hint + lens row + shutter + type box + gaps).
       ⚠️ ADD A CONTROL TO THIS COLUMN AND THIS NUMBER MOVES. */
    .stage{height:clamp(240px, calc(100svh - 360px), 470px);min-height:240px}
  }
  /* project + tabs (segmented) + list */
  #proj{margin-bottom:10px}
  /* 🐛 THIS WAS `nowrap` AND IT HID CONTROLS (2026-08-19). Six action buttons plus the
     Captured/Review segment need ~737px; in the desktop right-hand column they get ~520px, so
     Tag CSV, Verify all and CSV were painted OUTSIDE the box — in the DOM, unreachable, with
     `body{overflow-x:hidden}` swallowing the scrollbar that would have revealed them. Exactly
     the header bug from the 2026-08-12 pass, in a container that pass did not check.
     ⚠️ Wrapping, NOT `overflow-x:auto`: a horizontally scrollable strip hides controls behind a
     gesture nobody performs, which is how they went missing in the first place. */
  .tabs{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 12px;align-items:center}
  /* The segment stays whole and the buttons wrap around it. */
  .tabs .seg{flex:0 0 auto}
  .tabs .grow{flex:1 1 auto}
  .seg{display:inline-flex;background:var(--bg2);border:1px solid var(--line);border-radius:12px;padding:3px;gap:2px}
  .seg button{min-height:44px;border:none;background:none;color:var(--mut);padding:7px 14px;border-radius:9px;font-weight:600}
  .seg button.on{background:var(--acc);color:#fff}
  .csvbtn{min-height:44px;padding:9px 13px;font-size:13px}
  /* Weigh replaces the Captured list, so the button has to show which mode you are in —
     .on is only styled for .seg buttons and this one looked inert while active. */
  .csvbtn.on{background:var(--acc);color:#fff;border-color:var(--acc)}
  /* Shown ONLY while the live connection is actually down. A permanent "live" light is
     decoration; this is the one state the operator needs to know about, because a dead
     stream looks exactly like a quiet tray. */
  #liveWarn{display:none}
  #liveWarn.on{display:inline-flex;background:rgba(240,169,58,.14);border-color:var(--warn);color:var(--warn)}
  /* Always-on manual entry, directly under the shutter so it is in the same thumb arc as the
     button the operator is already pressing — one hand, the other holding the piece. */
  .manual{display:flex;gap:8px;margin-top:8px}
  #manualIn{flex:1;min-width:0;min-height:46px;padding:10px 14px;background:var(--bg2);
    border:1px solid var(--line);border-radius:var(--r);color:var(--fg);
    font-family:ui-monospace,Menlo,monospace;font-size:19px;letter-spacing:4px;text-transform:uppercase}
  #manualIn::placeholder{font-family:-apple-system,system-ui,sans-serif;font-size:14px;letter-spacing:0;text-transform:none;color:var(--mut)}
  #manualIn:focus{outline:none;border-color:var(--acc)}
  #manualIn.bad{border-color:var(--danger)}
  /* The nudge: when a scan has been in flight long enough that waiting is a gamble, the
     certain path lights up. Deliberately a border, not a modal and not a focus steal — the
     operator may still be framing the next piece. */
  #manualIn.nudge{border-color:var(--warn);box-shadow:0 0 0 3px rgba(240,169,58,.14)}
  #manualGo{min-height:46px;padding:0 18px;font-weight:700}
  #manualGo:disabled{opacity:.4}
  .row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:13px 14px;border:1px solid var(--line);border-radius:var(--r);margin-bottom:8px;background:var(--card)}
  .row.edit{flex-wrap:wrap}
  /* Pick pieces to print. Only shown once the shop is using tags at all (tagsInUse()) — a
     tick box on a sheet nobody prints is a control that can never do anything.
     44px of touch target on a phone held in one hand, with a piece in the other. */
  /* The box stays 22px because that is the right visual weight next to a 22px code; the TAP
     TARGET is the label wrapped around it, padded out to 44px and pulled back with negative
     margin so the row does not grow. A checkbox cannot be padded directly — it is a replaced
     element — which is why this needs the wrapper rather than a bigger box. */
  .selwrap{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;
    margin:-11px -11px -11px -11px;flex:none;cursor:pointer}
  .selbox{width:22px;height:22px;min-width:22px;accent-color:var(--ok);margin:0}
  .row.picked{border-color:var(--ok);background:rgba(49,196,141,.07)}
  #selBar{display:none;position:sticky;top:0;z-index:5;align-items:center;gap:10px;
     padding:9px 12px;margin-bottom:8px;border:1px solid var(--ok);border-radius:var(--r);
     background:rgba(49,196,141,.12)}
  #selBar.on{display:flex}
  #selBar .p{padding:8px 14px;min-height:44px}
  .hid{font-family:ui-monospace,Menlo,monospace;font-size:22px;letter-spacing:3px;font-weight:800}
  .row small{color:var(--mut);font-size:12px;white-space:nowrap}
  .iconbtn{background:transparent;border:1px solid transparent;padding:8px;font-size:17px;border-radius:9px;line-height:1;min-height:44px;min-width:44px}
  .iconbtn:active{background:var(--line)}
  .rowbtns{display:flex;gap:8px;width:100%;margin-top:10px}.rowbtns .p{flex:1}
  /* Edit-code and Delete live here rather than on the row: five icon buttons wrapped onto a
     second line even on a desktop, and these two are rare and destructive while the other
     three are used on every piece. */
  .rowbtns.rare{margin-top:8px;padding-top:8px;border-top:1px solid var(--line)}
  /* ⚠️ THESE ARE EDIT-CODE AND DELETE, AND THEY USED TO BE THE SMALLEST, FAINTEST CONTROLS IN
     THE APP — 38px at 80% opacity. De-emphasis is right (they are rare); doing it by SHRINKING
     and DIMMING the two destructive actions is not, because it makes them the hardest to hit
     deliberately and no harder to hit by accident. They are now full size and full contrast, and
     stay de-emphasised the honest way: a muted label on a quiet ground, below a divider. */
  .rowbtns.rare button{flex:1;font-size:13px;min-height:44px;color:var(--mut)}
  .rowbtns.rare button:hover{color:var(--fg)}
  .badge{font-size:11px;padding:3px 9px;border-radius:20px;font-weight:600}
  .b-ok{background:rgba(49,196,141,.15);color:var(--ok)}.b-rev{background:rgba(246,166,9,.15);color:var(--warn)}
  /* BIS verification pills — compact, sit next to the confidence badge */
  .b-bis-ok{background:rgba(49,196,141,.15);color:var(--ok)}
  .b-bis-no{background:rgba(255,107,107,.15);color:var(--danger)}
  .b-bis-pend{background:var(--acc-weak);color:#aecaff;animation:bispulse 1.2s ease-in-out infinite}
  .b-bis-na{background:rgba(240,169,58,.16);color:#f0a93a}          /* BIS unreachable (system), not a verdict on the piece */
  .b-bis-unk{background:rgba(148,163,181,.15);color:var(--mut)}
  /* Reached the sheet on a registry-name match rather than on two readers agreeing (sql/27).
     It MUST look different from a corroborated row: with the name check doing the vouching,
     the operator's glance is the only thing left that can catch a wrong piece. */
  .b-auto{background:rgba(240,169,58,.16);color:#f0a93a}
  .b-bis-unv{color:var(--mut);font-weight:500;font-size:10.5px;padding-left:2px}
  /* The registry's facts, on their own line under the code. Five of them don't fit
     beside a 22px letter-spaced HUID on a phone, and truncating them would defeat the
     point of showing them. Wraps to as many lines as it needs. */
  .bline{flex-basis:100%;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 8px;margin-top:-2px}
  .bmeta{color:var(--mut);font-size:11.5px;font-family:ui-monospace,monospace}
  .bfirm{color:var(--fg);opacity:.75;font-size:11.5px;font-weight:600}
  /* ---- barcode tags ---- */
  /* Tray defaults. Category and purity are constant across a tray far more often than not,
     so they are set ONCE here rather than retyped on all 200 pieces; only the weight, which
     genuinely differs per piece, is asked for per scan. */
  #tagbar{display:none;gap:8px;flex-wrap:wrap;padding:12px;border:1px solid var(--line);
          border-radius:var(--r);background:var(--bg2);margin-bottom:10px}
  #tagbar.open{display:flex}
  #tagbar input{flex:1 1 130px;min-width:0;margin:0}
  #tagbar input#tPre{flex:0 0 74px}
  #tagbar .tnote{flex-basis:100%;color:var(--mut);font-size:11.5px;line-height:1.45;margin:0}
  .tline{flex-basis:100%;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;margin-top:-2px}
  .tno{font-family:ui-monospace,monospace;font-size:11.5px;font-weight:700;color:var(--acc2,#7aa7ff)}
  .tmeta{color:var(--mut);font-size:11.5px;font-family:ui-monospace,monospace}
  .tneed{color:var(--warn);font-size:11.5px;font-weight:600}
  .row.tagged{border-left:3px solid var(--ok)}
  .dgrid{flex-basis:100%;display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
  .dgrid input{flex:1 1 108px;min-width:0;margin:0;font-size:16px}   /* 16px floor — see the input rule */
  /* Visually hidden, still read by screen readers and still claimed by autofill. Used where a
     visible label genuinely will not fit; everywhere with room gets a real one. */
  /* One empty-state shape for the whole app: what would be here, and the one action that
     fills it. Adopted from the projects page, which was the only surface doing this well. */
  .empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;
    text-align:center;padding:34px 18px;color:var(--mut);font-size:13.5px;line-height:1.5}
  .empty b{color:var(--fg);font-size:15px;font-weight:600}
  .vh{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
      clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
  /* A NAME THAT SURVIVES BEING FILLED IN. A placeholder is gone the moment the operator types,
     so a half-completed details form was four anonymous boxes — and the weigh pass was a column
     of them, entered at speed without looking up. The name now sits above the value. */
  .dfield{display:flex;flex-direction:column;gap:3px;flex:1 1 108px;min-width:0}
  .dfield>span{font-size:11px;letter-spacing:.04em;color:var(--mut);font-weight:600}
  .dfield input{width:100%;flex:none}
  .fieldrow{display:flex;flex-direction:column;gap:3px;margin-bottom:8px}
  .fieldrow>span{font-size:11px;letter-spacing:.04em;color:var(--mut);font-weight:600}
  /* Batch weigh pass — scanning and weighing are two physical passes, not one. */
  .wrow{display:flex;align-items:center;gap:10px;padding:9px 12px;border:1px solid var(--line);
        border-radius:var(--r);margin-bottom:6px;background:var(--card)}
  .wrow.done{border-color:rgba(49,196,141,.35)}
  .wrow .hid{font-size:17px;letter-spacing:2px}
  .wrow input{width:110px;margin:0;font-size:17px;text-align:right;font-family:ui-monospace,monospace}
  .wrow small{color:var(--mut);font-size:11.5px}
  #weighHead{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;padding:10px 12px;margin-bottom:8px;
             border:1px solid var(--line);border-radius:var(--r);background:var(--bg2)}
  #weighHead b{font-size:15px}
  .trayprog{color:var(--mut);font-size:12px}
  .trayprog b{color:var(--fg)}
  .trayshort{color:var(--warn);font-weight:600}
  /* help / quick-guide sheet */
  .ovl{position:fixed;inset:0;z-index:50;background:rgba(0,0,0,.6);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:flex-end;justify-content:center}
  .ovl-sheet{background:var(--bg);width:100%;max-width:560px;max-height:88vh;border:1px solid var(--line);border-radius:var(--r-lg) var(--r-lg) 0 0;display:flex;flex-direction:column;box-shadow:var(--shadow)}
  @media(min-width:720px){ .ovl{align-items:center} .ovl-sheet{border-radius:var(--r-lg);max-height:82vh} }
  .ovl-head{display:flex;align-items:center;padding:15px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);border-radius:var(--r-lg) var(--r-lg) 0 0}
  .ovl-head b{font-size:16px}
  .ovl-x{margin-left:auto;background:none;border:none;color:var(--mut);font-size:18px;cursor:pointer;min-height:0;padding:2px 6px}
  .ovl-body{padding:14px 18px calc(18px + var(--sab));overflow-y:auto;line-height:1.55;font-size:14px}
  .ovl-body h3{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--acc);margin:20px 0 7px}
  .ovl-body h3:first-child{margin-top:2px}
  .ovl-body p{margin:6px 0}
  .ovl-body ol,.ovl-body ul{margin:4px 0 8px;padding-left:20px}
  .ovl-body li{margin:6px 0}
  .glegend{list-style:none;padding-left:0}
  .gnote{font-size:12px;color:var(--mut2);margin:2px 0 10px;line-height:1.45}
  .glegend li{display:flex;align-items:center;gap:8px}
  .g-badge{flex:none;font-size:11px;padding:3px 9px;border-radius:20px;font-weight:600}
  .beta{display:inline-block;margin-left:7px;padding:1px 6px;border-radius:999px;font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;background:var(--acc-weak);color:#aecaff;vertical-align:middle}
  /* plain-language status banners above the list */
  .notice{display:block;width:100%;box-sizing:border-box;margin:0 0 8px;padding:9px 11px;border-radius:10px;font-size:12.5px;line-height:1.45}
  .notice.warn{background:rgba(240,169,58,.12);color:#f0a93a;border:1px solid rgba(240,169,58,.3)}
  .notice.info{background:rgba(148,163,181,.1);color:var(--mut)}
  .linkbtn{background:none;border:none;color:inherit;text-decoration:underline;cursor:pointer;font-weight:700;padding:0 2px;font-size:12.5px}
  @keyframes bispulse{0%,100%{opacity:.55}50%{opacity:1}}
  /* ---- instant BIS failure alerts (async verification lands while scanning) ---- */
  #alertStack{position:fixed;left:0;right:0;top:0;z-index:60;display:flex;flex-direction:column;gap:6px;
    padding:8px;pointer-events:none;max-width:640px;margin:0 auto}
  .atoast{pointer-events:auto;display:flex;gap:9px;align-items:flex-start;cursor:pointer;
    background:#2b1214;border:1px solid rgba(255,107,107,.55);color:#ffc2c2;
    border-radius:12px;padding:10px 12px;font-size:13px;line-height:1.4;
    box-shadow:0 10px 28px rgba(0,0,0,.5);animation:atin .18s ease-out}
  .atoast.gone{animation:atout .18s ease-in forwards}
  /* green variant: the registry CONFIRMED a review row and it moved to the sheet */
  .atoast.ok{background:#0f2a20;border-color:rgba(51,205,146,.55);color:#bff0dc}
  .atoast.ok small{color:#8fd8bb}
  /* Errors used to be native alert(): a modal that blocks the page, cannot be reached
     one-handed at the top of a large phone, and costs a second tap to clear. They land here
     instead — same stack, same aria-live, no tap required. */
  .atoast.err{background:#2b1416;border-color:rgba(255,107,107,.5);color:#ffd7d7}
  .atoast .hid{font-family:ui-monospace,monospace;letter-spacing:2px;color:#fff;font-weight:700}
  .atoast small{display:block;color:#e79a9a;margin-top:2px}
  @keyframes atin{from{transform:translateY(-10px);opacity:0}to{transform:none;opacity:1}}
  @keyframes atout{to{transform:translateY(-10px);opacity:0}}
  /* verification tally strip under the piece/weight tray */
  .tstats{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 8px}
  .tstat{font-size:11.5px;font-weight:700;padding:4px 9px;border-radius:999px;white-space:nowrap}
  .tstat.ok{background:rgba(49,196,141,.15);color:var(--ok)}
  .tstat.bad{background:rgba(255,107,107,.16);color:#ff8b8b;cursor:pointer}
  .tstat.wait{background:rgba(148,163,181,.14);color:var(--mut);animation:bispulse 1.6s ease-in-out infinite}
  .tstat.na{background:rgba(240,169,58,.14);color:#f0a93a}
  /* persistent counter — the toast can be missed while the operator looks at the piece */
  .flagchip{background:rgba(255,107,107,.16);color:#ff8b8b;border:1px solid rgba(255,107,107,.45);
    cursor:pointer;font-weight:700}
  .row.flagged{border-color:rgba(255,107,107,.45)}
  .row.jumped{animation:rowflash 1.4s ease-out}
  @keyframes rowflash{0%,40%{background:rgba(255,107,107,.22)}100%{background:transparent}}
  .review-edit{font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:3px;text-align:center;font-size:20px;width:100%}
  /* the frame the models choked on, shown next to the readings so the operator can judge */
  /* the registry vouching for a review-lane code — evidence, not a decision */
  .vouch{width:100%;background:rgba(49,196,141,.08);border:1px solid rgba(49,196,141,.28);
         border-radius:10px;padding:9px 12px;margin-top:8px;line-height:1.45}
  .vouch b{color:var(--ok);font-size:13px}
  .vouch small{display:block;color:var(--mut);font-size:11.5px}
  .row.vouched{border-color:rgba(49,196,141,.3)}
  .revthumb{width:96px;height:56px;object-fit:cover;border-radius:8px;background:#000;flex:0 0 auto;cursor:zoom-in}
  .revthumb:hover{outline:2px solid var(--acc)}
  .alts{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
  .alts button{padding:6px 12px;font-family:ui-monospace,monospace;min-height:44px}
  .muted{color:var(--mut);font-size:13px}
  /* live result card over the camera */
  /* ⚠️ z-index 8, NOT 5 — the torch and the zoom bar are z-index 6 and the focus ring 7, so a
     result card used to be painted UNDER them: the torch button sat directly on top of the
     sixth character slot. It stays BELOW the lens picker (9) and the native-crop overlay (10),
     which are deliberate full-screen takeovers.
     ⚠️ AND IT MUST BE HEIGHT-CAPPED. `.stage` is `overflow:hidden`, so a card taller than the
     viewfinder is silently CLIPPED AT THE TOP — which is where the heading is. The partial-read
     card (six slots + candidate chips) is the first one tall enough to hit it. */
  .result{position:absolute;left:8px;right:8px;bottom:8px;border-radius:var(--r-lg);padding:14px 16px;z-index:8;box-shadow:var(--shadow);max-height:calc(100% - 16px);overflow-y:auto;overscroll-behavior:contain}
  .result.ok{display:flex;align-items:center;gap:12px;background:var(--ok);color:#04140d;animation:pop .18s ease}
  .result.dup{display:flex;align-items:center;gap:12px;background:var(--acc);color:#04101f;animation:pop .18s ease}
  .result.ok .tick,.result.dup .tick{font-size:28px;font-weight:800;line-height:1}
  .result .rid{font-family:ui-monospace,Menlo,monospace;font-size:30px;font-weight:800;letter-spacing:4px}
  .result .rmsg{margin-left:auto;font-weight:700;opacity:.85;font-size:13px}
  .result.rev{background:rgba(14,20,28,.98);border:1px solid var(--warn);animation:pop .18s ease}
  .result .rev-top{font-size:13.5px;color:var(--warn);font-weight:700;margin-bottom:10px}
  .cands{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
  .cand{flex:1;min-width:132px;font-family:ui-monospace,Menlo,monospace;font-size:26px;font-weight:800;letter-spacing:3px;padding:14px 8px;border:2px solid var(--acc);background:rgba(79,140,255,.16);color:var(--fg);border-radius:12px;cursor:pointer}
  .cand:active{background:var(--acc);color:#fff;transform:scale(.97)}
  .rev-actions{display:flex;gap:8px;align-items:center}
  .rev-actions input{flex:1;min-width:70px;text-align:center;text-transform:uppercase;letter-spacing:3px;font-family:ui-monospace,monospace;margin:0}
  /* The DRAFT card: the first reader's code, editable, while the second is still checking.
     Deliberately NOT green and NOT the warning orange — it is neither a result nor a problem,
     it is work in progress, so it gets the neutral accent and a pulse that says "still going". */
  .result.draft{background:rgba(14,20,28,.98);border:1px solid var(--acc);animation:pop .18s ease}
  .result.draft .rev-top{color:var(--acc)}
  /* 16px floor is not a typography choice — below it iOS zooms the whole page on focus and
     never zooms back (see the UI/UX pass in CLAUDE.md). */
  .codein{flex:1;min-width:120px;text-align:center;text-transform:uppercase;letter-spacing:4px;
    font-family:ui-monospace,Menlo,monospace;font-size:26px;font-weight:800;padding:10px 6px;
    border:2px solid var(--acc);background:rgba(79,140,255,.16);color:var(--fg);border-radius:12px;margin:0}
  .rev-note{margin-top:8px;font-size:12px;color:var(--mut)}
  /* PARTIAL READ: the characters two readers agreed on, plus the ones they didn't.
     The disputed slot is the ONLY thing the operator has to check against the piece, so it is
     the only thing styled to demand attention. */
  .slots{display:flex;gap:6px;margin:2px 0 10px;justify-content:center}
  .slot{flex:1 1 0;min-width:0;max-width:56px;height:48px;text-align:center;padding:0;margin:0;
    font-family:ui-monospace,Menlo,monospace;font-size:24px;font-weight:800;text-transform:uppercase;
    background:var(--bg2);border:1px solid var(--line);border-radius:10px;color:var(--fg);caret-color:var(--acc)}
  /* Agreed: settled, and deliberately still editable — two readers CAN agree on a wrong glyph,
     so locking these would make the one thing a human can catch impossible to fix. */
  .slot.ok{border-color:var(--line2);color:var(--fg)}
  .slot.gap{border-color:var(--warn);background:rgba(240,169,58,.10);box-shadow:0 0 0 3px rgba(240,169,58,.12)}
  .slot:focus{outline:none;border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-weak)}
  .slotopts{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}
  .slotopt{min-height:44px;min-width:48px;font-family:ui-monospace,Menlo,monospace;font-size:20px;
    font-weight:800;padding:8px 14px;border:2px solid var(--acc);background:rgba(79,140,255,.16);
    color:var(--fg);border-radius:12px;cursor:pointer}
  .dspin{display:inline-block;width:7px;height:7px;margin-left:7px;border-radius:50%;
    background:var(--acc);animation:dpulse 1s ease-in-out infinite;vertical-align:middle}
  @keyframes dpulse{0%,100%{opacity:.25}50%{opacity:1}}
  .ghost{background:transparent}
  @keyframes pop{from{transform:scale(.96) translateY(4px);opacity:.3}to{transform:scale(1) translateY(0);opacity:1}}
