:root{
  --navy: #2c313a;
  --navy-2: #23272e;
  --orange: #e8601c;
  --orange-dark: #c94f14;
  --panel: #d7d7d5;
  --panel-2: #c7c7c5;
  --ink: #23272e;
  --ink-soft: #5a5e64;
  --line: #b9b9b6;
  --ok: #2f8f4e;
  --err: #c93a3a;
  --info: #2f6fa8;
  --edit: #7c5cbf;
  --white: #f3f2ef;
}
*{box-sizing:border-box;}
[hidden]{display:none !important;}
body{
  margin:0;
  font-family:'Inter',sans-serif;
  background:var(--navy);
  color:var(--ink);
}

/* 336px = 240px * 1.4, matching the desktop content scale below -- a
   plain px change (not zoom), since grid column widths aren't subject to
   the vh/zoom interaction .sidebar-inner's comment below explains. Mobile
   fully replaces this with a single 1fr column further down, so this
   never affects it either way. */
.app{
  display:grid;
  grid-template-columns:336px 1fr;
}

/* ---------- Sidebar ---------- */
/* .sidebar itself is deliberately NEVER zoomed, even on desktop -- only
   .sidebar-inner (its one child, see index.html) is. `height:100vh` and
   `zoom` don't mix safely: vh always measures the TRUE viewport no matter
   what zoom an ancestor has, so a zoomed height:100vh element would
   compute its 100vh against the real screen and then render 1.4x TALLER
   than that -- overflowing past the bottom of the screen and dragging the
   whole page into a scroll it doesn't need, exactly the bug Stian hit
   with the previous whole-body-zoom attempt. Keeping .sidebar's own
   height/overflow/position real and un-zoomed, and scaling only its
   child, means .sidebar-inner can safely grow past 100vh on a short
   screen and just scroll WITHIN the sidebar (overflow-y:auto below)
   instead of stretching the page. */
.sidebar{
  background:var(--orange);
  padding:24px 18px;
  height:100vh;
  overflow-y:auto;
  position:sticky;
  top:0;
}
/* min-height:100% (not a fixed height) -- needs SOME height to give
   .nav-spacer's flex:1 something to grow against and push Log out to the
   bottom on a normal/tall screen (matching the original behavior), but as
   a MINIMUM rather than a cap, so it can still grow taller than that and
   let .sidebar's own scrollbar take over once the desktop-zoomed content
   (see the @media (min-width:769px) block further down) genuinely needs
   more room than the screen has. */
.sidebar-inner{
  display:flex;
  flex-direction:column;
  gap:10px;
  min-height:100%;
}
/* On short screens, stop reserving space to push Settings to the
   bottom — let it sit in normal flow so nothing gets clipped. */
@media (max-height:640px){
  .nav-spacer{flex:none;height:6px;}
}
.logo{
  display:flex;
  align-items:center;
  gap:8px;
  margin-bottom:26px;
}
.logo svg, .logo-mark{width:26px;height:26px;flex-shrink:0;}
/* Upright, not italic -- per Stian, not a fan of the slanted look. */
.logo span{
  font-family:'Oswald',sans-serif;
  font-weight:700;
  font-style:normal;
  font-size:24px;
  letter-spacing:.02em;
  color:var(--navy-2);
  text-transform:uppercase;
}
/* A real drawn vertical rule rather than a "|" text glyph -- the glyph
   read as tilted/slanted (closer to "/" than a straight line) at this
   size in Oswald, independent of italics. */
.logo-divider{
  width:3px;
  align-self:stretch;
  min-height:22px;
  background:var(--navy);
  
}
/* Sidebar only (not the auth-gate/superadmin uses of .logo): desktop
   drops the mark/divider entirely and shows just the store name (Stian)
   -- min-width:0 lets the label actually shrink/wrap within the row
   instead of forcing .logo wider than the sidebar (flex items default to
   min-width:auto, their unwrapped content width, which is what let a
   long store name push past the sidebar's edge). overflow-wrap/word-break
   both normal so a long name ("Power Lillehammer") wraps between words
   rather than splitting mid-word. Mobile flips this back the OTHER way
   (mark only, no name) -- see the @media (max-width:768px) block further
   down, which restores the mark and hides the divider/label instead. */
@media (min-width:769px){
  .sidebar .logo-mark, .sidebar .logo-divider{display:none;}
  /* Nav buttons have their own 14px left padding (.nav-btn below) around
     their icon/label -- with the mark+divider gone, the label was the
     first thing in .logo with nothing of its own to match that inset,
     so it started flush with .sidebar-inner's edge instead of lining up
     with every nav-btn label underneath it (Stian: "too early"). */
  .sidebar .logo{padding-left:14px;}
}
.sidebar #sidebar-logo-label{flex:1;min-width:0;font-size:16px;line-height:1.2;overflow-wrap:normal;word-break:normal;}
.nav-btn{
  position:relative;
  background:var(--navy-2);
  color:var(--white);
  border:none;
  border-radius:3px;
  padding:13px 14px;
  text-align:left;
  font-family:'Inter',sans-serif;
  font-size:14px;
  font-weight:500;
  cursor:pointer;
  transition:background .15s;
  display:flex;
  align-items:center;
  gap:10px;
}
.nav-btn-label{flex:1;}
/* Small corner sticker flagging Sweep Planner as still new/being tried
   out -- position:relative on .nav-btn above is what lets this pin to
   the button's own corner. Sits INSIDE the button's box (not hanging off
   the edge) on purpose -- .nav-btn gets overflow:hidden on mobile (for
   the label's text-ellipsis truncation), which would otherwise clip a
   badge positioned outside the box on the exact viewport this app is
   mostly used on. */
.nav-badge-beta{
  position:absolute;
  top:4px;
  right:4px;
  background:var(--orange);
  color:var(--white);
  font-family:'IBM Plex Mono',monospace;
  font-size:9px;
  font-weight:700;
  text-transform:uppercase;
  letter-spacing:.03em;
  padding:2px 5px;
  border-radius:3px;
  line-height:1.3;
}
/* Same viewBox/stroke-width convention across every nav icon (line-art,
   currentColor) so they read as one consistent set despite covering very
   different subjects. Icon always comes first, label after -- same order
   for every button, including Search (it's a nav button, not a search
   input, so it doesn't get the trailing-icon treatment a real search
   field would). */
.nav-icon{width:17px;height:17px;flex-shrink:0;}
.nav-btn:hover{background:#31363f;}

/* ---------- Nav icon hover animations ---------- */
/* Search: three short "letters" pop through the magnifying glass, one
   after another. */
.search-letter{opacity:0;transform-box:fill-box;transform-origin:center;}
.nav-btn:hover .search-letter{animation:search-letter-pop .7s ease;}
.nav-btn:hover .search-letter-1{animation-delay:0s;}
.nav-btn:hover .search-letter-2{animation-delay:.12s;}
.nav-btn:hover .search-letter-3{animation-delay:.24s;}
@keyframes search-letter-pop{
  0%, 100%{opacity:0;transform:scaleY(.2);}
  35%, 75%{opacity:1;transform:scaleY(1);}
}

/* Place product: a red scan line sweeps once across the barcode. */
.barcode-scan-line{opacity:0;}
.nav-btn:hover .barcode-scan-line{animation:barcode-scan .8s ease-in-out;}
@keyframes barcode-scan{
  0%{opacity:0;transform:translateX(0);}
  12%{opacity:1;}
  85%{opacity:1;}
  100%{opacity:0;transform:translateX(18px);}
}

/* Movement log: the hands sweep one full turn counterclockwise. Explicit
   px transform-origin (matching the circle's own viewBox center) rather
   than transform-box:fill-box -- the hands' own bounding box isn't
   centered on the clock face, so fill-box would pivot around the wrong
   point. */
.clock-hands{transform-origin:12px 12px;}
.nav-btn:hover .clock-hands{animation:clock-hands-spin .8s linear;}
@keyframes clock-hands-spin{to{transform:rotate(-360deg);}}

/* Storage map: a ballerina-style leap -- jumps up, does one quick
   mid-air twirl (a horizontal flip, not an in-plane rotate -- rotating a
   flat icon in its own plane just looks like a coin spinning on a table,
   not a jump), then lands. */
.map-cube{transform-origin:12px 12px;}
.nav-btn:hover .map-cube{animation:map-cube-jump .6s ease;}
@keyframes map-cube-jump{
  0%{transform:translateY(0) scaleX(1);}
  25%{transform:translateY(-6px) scaleX(1);}
  50%{transform:translateY(-6px) scaleX(-1);}
  75%{transform:translateY(-6px) scaleX(1);}
  100%{transform:translateY(0) scaleX(1);}
}

/* Overview: the three list lines spread apart slightly. */
.list-line{transition:transform .2s ease;}
.nav-btn:hover .list-line-1{transform:translateY(-1.5px);}
.nav-btn:hover .list-line-3{transform:translateY(1.5px);}

/* Settings: the cog "clicks" forward twice, like a ratchet, then resets. */
.settings-cog{transform-origin:12px 12px;}
.nav-btn:hover .settings-cog{animation:cog-click .6s steps(1, end);}
@keyframes cog-click{
  0%{transform:rotate(0deg);}
  30%, 45%{transform:rotate(18deg);}
  75%, 90%{transform:rotate(36deg);}
  100%{transform:rotate(0deg);}
}
.nav-btn.active{
  background:var(--navy);
  box-shadow:inset 3px 0 0 var(--white);
}
.nav-spacer{flex:1;}

/* ---------- Mobile topbar + sidebar drawer (mobile only) ----------
   Both fully inert on desktop by default -- the @media (max-width:768px)
   block further down is the only place that turns .mobile-topbar on and
   gives .sidebar its off-canvas drawer behavior. */
.mobile-topbar{display:none;}
.mobile-page-dots{display:none;}
.sidebar-backdrop{display:none;}

/* ---------- Main ---------- */
main{
  background:var(--navy);
  padding:24px 28px 40px;
  display:flex;
  flex-direction:column;
  /* main is a grid item (.app's "1fr" column) -- grid items default to a
     minimum size equal to their content's intrinsic width, not 0. A wide
     room (e.g. 37 grid-columns = 2220px of fixed-width .grid-2d-content
     buried inside it) was silently forcing that minimum past the actual
     available space, stretching the whole page wider than the viewport
     and producing the horizontal scrollbar -- overflow:hidden on the map
     containers alone can't fix this, since the oversizing happens one
     layout level up, before any of their own clipping ever applies. */
  min-width:0;
}
/* #main-content used to BE main itself -- now that it's a child (see its
   own comment further down, and index.html), it needs main's own
   display:flex/flex-direction:column/min-width:0 repeated one level
   deeper, or every page's real content (.place-panel's centering,
   .search-panel-wrap's, wide rooms in Storage map, ...) loses the flex
   context it's always depended on. flex:1 is the one genuinely NEW
   property here -- #main-content needs to actually fill main's full
   height itself now (main's OWN height already comes from the sidebar
   being taller than any page's content, same as always), for that same
   child centering to have real leftover space to distribute in the
   first place. */
#main-content{
  display:flex;
  flex-direction:column;
  flex:1;
  min-width:0;
}
/* Mobile-only live preview layer (page-swipe, script.js) -- inert and
   invisible everywhere else, matching .mobile-topbar/.sidebar-backdrop's
   own base-hidden-until-mobile convention above. */
#main-content-peek{display:none;}
/* A teeny "jump" on every page nav instead of the new page just
   statically appearing (Stian) -- .content-panel (and .sweep-tabs, which
   sits as its own sibling above Sweep Planner's content-panels rather
   than inside one -- looked visibly "left behind" bouncing in without it)
   are freshly-INSERTED elements each time (script.js's loadPage does
   mainContent.innerHTML = html, replacing the page fragment wholesale;
   #main-content itself is the one persistent node, so animating THAT
   wouldn't replay on nav the way animating its freshly-created children
   does), which is exactly what makes a plain always-on `animation`
   property here replay automatically on every single navigation with no
   JS involved -- no class-toggling or reflow-forcing needed, a genuinely
   new element always (re-)runs its own animation on insert. Same
   keyframes/timing on both so they read as one small bounce, not two
   separate ones. .page-title deliberately does NOT get this (Stian) --
   just the panel(s) below it. cubic-bezier's overshoot is what gives the
   tiny spring feel -- a plain ease-out would just be a slide, no "jump"
   to it. prefers-reduced-motion (OS/browser accessibility setting) is a
   real opt-out, same as the Search minimap's own pan/zoom (see
   prefersReducedMotion in iso-render.js) -- disabled outright there
   rather than just shortened, since this one has no functional purpose
   to preserve. */
@keyframes page-bounce-in{
  0%{transform:translateY(6px);opacity:0;}
  60%{transform:translateY(-2px);opacity:1;}
  100%{transform:translateY(0);}
}
.content-panel, .sweep-tabs{animation:page-bounce-in .28s cubic-bezier(.34,1.56,.64,1);}
@media (prefers-reduced-motion:reduce){
  .content-panel, .sweep-tabs{animation:none;}
}
/* Same 768px cutoff as the shared isMobileViewport() helper (script.js)
   -- reads fine as a little spring on a big desktop panel, but per Stian
   it just looks like a jump/glitch at phone width, where the panel is
   already full-viewport-width and the motion has nowhere to "settle"
   into. */
@media (max-width:768px){
  .content-panel, .sweep-tabs{animation:none;}
}
.page-header{margin-bottom:16px;}
.page-title{
  font-family:'Oswald',sans-serif;
  font-weight:600;
  font-size:26px;
  text-transform:uppercase;
  color:var(--white);
  margin:2px 0 0;
}

.content-panel{
  background:var(--panel);
  border-radius:4px;
  padding:26px;
  flex:1;
  min-height:600px;
}

/* ---------- Data-loading skeleton (Settings/Movement log/Overview) ----------
   These three pages' real content is populated by a fetch that runs after
   the fragment itself has already loaded and rendered (empty table, "—"
   placeholders) -- fixing the actual network gap turned out to be a moving
   target (see script.js's page/script caching, which fixed a DIFFERENT gap
   but not this one), so per Stian: simpler and more robust to just cover it
   with a skeleton the whole time data is in flight, then fade the real
   thing in via a plain CSS transition once it's ready, rather than chasing
   the timing. .page-real-content starts hidden entirely (not just
   transparent) so the skeleton alone determines the panel's height while
   loading; the page's own script removes .page-data-skeleton and adds
   .revealed once its initial fetch resolves. */
.page-data-skeleton{display:flex;flex-direction:column;gap:12px;}
.page-skeleton-block{
  height:52px;
  border-radius:6px;
  background:linear-gradient(90deg, var(--panel-2) 25%, var(--line) 37%, var(--panel-2) 63%);
  background-size:400% 100%;
  animation:page-skeleton-shimmer 1.4s ease infinite;
}
.page-skeleton-block-lg{height:120px;}
.page-skeleton-block-sm{width:60%;}
@keyframes page-skeleton-shimmer{
  0%{background-position:100% 0;}
  100%{background-position:0 0;}
}
.page-real-content{display:none;opacity:0;transition:opacity .25s ease;}
.page-real-content.revealed{display:block;}
/* Two-step (display swapped in first, opacity animated a frame later --
   see the JS that adds this class) since a transition can't play across a
   display:none -> display:block jump in one step. */
.page-real-content.revealed.fade-in{opacity:1;}

/* A single value (a name, a number) that's late for the same reason as
   the block-level skeletons above -- too small to justify hiding its
   whole row, so it gets an inline shimmering placeholder sitting right
   next to the (empty-for-now) real text instead. Reveal (settings.js)
   sets the real text, fades .s-value in, and shrinks+fades .s-value-
   skeleton away at the same time -- a crossfade rather than one
   instantly replacing the other. */
.s-value-wrap{display:inline-flex;align-items:center;justify-content:flex-end;}
.s-value-skeleton{
  display:inline-block;
  width:70px;
  height:13px;
  border-radius:4px;
  background:linear-gradient(90deg, var(--panel-2) 25%, var(--line) 37%, var(--panel-2) 63%);
  background-size:400% 100%;
  animation:page-skeleton-shimmer 1.4s ease infinite;
  vertical-align:middle;
  transition:width .25s ease, opacity .25s ease;
  overflow:hidden;
}
.s-value-skeleton.s-value-skeleton-gone{width:0;opacity:0;}
.s-value{opacity:0;transition:opacity .25s ease;}
.s-value.revealed{opacity:1;}

/* Overview and Movement log's table<->card breakpoint (further down)
   needs to measure THIS panel's own rendered width, not the viewport --
   the sidebar (336px) plus this panel's own padding means the panel is
   always narrower than the viewport, so a viewport-width media query was
   still showing the desktop table well past the point it actually got
   cramped inside the panel (Stian, tested on an iPad). Scoped to
   .tables-panel (Overview/Log only, via list.html/log.html) rather than
   every .content-panel user -- Edit Map and Storage Map both have
   position:fixed full-viewport overlays living inside .content-panel
   (.rack-side-panels, .map-view-panel), and container-type:inline-size
   implies layout containment, which would re-anchor those to the panel
   instead of the viewport if applied here globally. */
.tables-panel{container-type:inline-size;}
/* Search results unroll downward like a roller map, instead of the panel
   just popping to full size the instant results exist (Stian). Real
   `height` (not max-height, not min-height -- neither one gives a CSS
   transition two comparable real numbers to animate between; see
   search.js) driven between 0 and the content's own measured height, so
   this box's edge IS the roller: overflow:hidden here is PERMANENT, not
   toggled, so whatever's inside can never render past however far the
   roll has currently gotten, in either direction. #search-panel itself
   (the shared parent this column and .search-minimap-frame both sit in)
   carries no overflow/height logic of its own at all -- deliberately,
   since the minimap uses position:sticky, and overflow:hidden on an
   ANCESTOR of a sticky element redefines the containing block its
   "stuck" bounds are measured against. An earlier version put the
   overflow toggle on #search-panel and the minimap visibly recalculated
   against that still-animating box for the whole transition, snapping
   back to its real position only once it settled. #search-results-col
   is a plain SIBLING of the minimap frame, not an ancestor, so animating
   only this can never do that. min-height:0/flex:none on #search-panel
   override .content-panel's own base rule (600px min-height, flex:1) --
   without them the empty/compact state would either be forced to 600px
   regardless of this column's real (possibly 0) height, or stretch to
   fill the whole sidebar column on a tall monitor. */
/* Wrapper carries the min-height instead of #search-panel itself -- the
   panel is completely unchanged from its normal behavior (still fully
   opaque, still min-height:0/shrinks to fit exactly like every other
   .content-panel override on this page), it just doesn't fill the whole
   wrapper while collapsed/empty. .search-logo-mark (a sibling of
   #search-panel, not a descendant -- Stian wanted it structurally BETWEEN
   main and the panel's own content) sits behind it, inset:0 to the
   WRAPPER, so it's visible in the navy gap below the small collapsed
   panel. Once results grow, the panel's own real height animation
   (#search-results-col below) extends the panel's opaque box down over
   that same space, covering the mark like a curtain closing -- genuinely
   the panel doing the covering this time, not a same-sized stand-in
   layer, no JS involved beyond the height animation that already existed. */
.search-panel-wrap{position:relative;min-height:420px;}
#search-panel{min-height:0;flex:none;position:relative;z-index:1;}
.search-logo-mark{
  position:absolute;
  inset:0;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:8px;
  pointer-events:none;
  z-index:0;
  /* A small fixed nudge, not a percentage this time -- percentage-of-
     own-height was fine for the icon alone, but once the wordmark made
     the group taller it pushed far enough down to peek out below a
     modest result count's roll height. Small and fixed keeps it clear
     of the search bar without reopening that. */
  transform:translateY(48px);
}
.search-logo-mark img{width:150px;height:auto;}
/* None of the provided logo files are icon-on-top/wordmark-underneath
   (the lockup SVGs are icon-LEFT/text-RIGHT) -- Stian specifically
   wanted it stacked, so this is a plain text label styled to match the
   app's existing wordmark treatment (.logo span, sidebar/auth-gate)
   rather than a second SVG. White, not the usual navy, since this sits
   directly on the page's dark navy background. */
.search-logo-text{
  font-family:'Oswald',sans-serif;
  font-weight:700;
  font-size:22px;
  letter-spacing:.04em;
  color:var(--white);
  text-transform:uppercase;
}
#search-results-col{height:0;overflow:hidden;transition:height .35s ease;position:relative;z-index:1;background:var(--panel);}

/* ---------- Buttons / inputs shared ---------- */
.btn{
  font-family:'Oswald',sans-serif;
  font-weight:600;
  letter-spacing:.03em;
  text-transform:uppercase;
  font-size:13px;
  border:none;
  border-radius:3px;
  padding:12px 18px;
  cursor:pointer;
  background:var(--orange);
  color:var(--white);
}
.btn:hover{background:var(--orange-dark);}
.btn.ghost{
  background:transparent;
  border:1px solid var(--ink-soft);
  color:var(--ink);
}
/* Same gray-background/white-text look as the sidebar nav buttons. */
.btn.dark{background:var(--navy-2);color:var(--white);border:none;}
.btn.dark:hover{background:#31363f;}
.field-label{
  font-family:'IBM Plex Mono',monospace;
  font-size:11px;
  text-transform:uppercase;
  letter-spacing:.05em;
  color:var(--ink-soft);
  display:block;
  margin-bottom:6px;
}
/* Place product: shows the matched product's model name right after the
   label, in green, whenever the field currently holds an exact EAN match
   -- confirmation that what's about to be placed is really what's in the
   box. Not uppercase/letter-spaced like the rest of the label (that's
   tuned for the short all-caps label text, not a real product name). */
.place-match-indicator{color:var(--ok);text-transform:none;letter-spacing:normal;font-weight:600;}
/* Product input + "show available spaces" button share one row -- the
   button uses `hidden` (not disabled/greyed), so it isn't in the layout
   at all until a product actually matches, per Stian. flex:1 on the
   input (below) is what makes it shrink to make room the moment the
   button appears/disappears, rather than the button's own arrival
   pushing anything ELSE on the page around -- ordinary flexbox behavior,
   no JS needed. */
.place-input-row{display:flex;align-items:center;gap:8px;}
/* Width transition, not just left as a plain instant flex reflow -- the
   available-spaces button's SLOT (see .avail-slot-collapsed below)
   appearing/disappearing changes how much of the row is left for this
   input. Explicit two-step sequence, driven by place.js's showAvailBtn/
   hideAvailBtn, not something CSS alone decides the order of (Stian:
   "shrink input first, THEN animate button in" / "button out, THEN
   expand" -- two sequential steps, not simultaneous). */
.place-input-row .field-input{flex:1;min-width:0;transition:width .3s ease-out;}
/* Reuses the same rr-eye-closed/rr-eye-open SVG swap convention search's
   .rr-view-icon already established (see .rr-eye-open{display:none}
   above, a global rule any element using these two class names inherits
   for free) -- closed by default, opens on hover as the "yes, this is
   interactive" affordance, same as there.
   Two independent transitions, toggled by two separate classes so
   place.js can time them as two sequential steps instead of one
   simultaneous CSS animation:
   - width/border/padding -- the button's own SLOT in the row, collapsed
     to nothing by .avail-slot-collapsed. This is what the input's own
     width transition above is reacting to.
   - opacity/transform -- the icon's zoom-in-from-the-right look, hidden
     by .avail-icon-hidden. Purely cosmetic, doesn't affect layout/the
     input at all (transform never does).
   overflow:hidden so the icon doesn't visibly poke out while the slot
   is mid-collapse. */
.place-available-spaces-btn{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  flex:none;
  width:38px;
  height:38px;
  padding:0;
  border:1px solid var(--line);
  border-radius:3px;
  background:var(--white);
  color:var(--ink-soft);
  cursor:pointer;
  overflow:hidden;
  opacity:1;
  transform:scale(1) translateX(0);
  transition:width .3s ease-out, border-width .3s ease-out, opacity .25s ease, transform .25s ease;
}
.place-available-spaces-btn.avail-slot-collapsed{
  width:0;
  border-width:0;
  /* Stays in the DOM/flex flow permanently now (see place.js's own
     comment on why) rather than display:none, so this guards against an
     accidental click landing on a technically-still-there 0-width
     button. */
  pointer-events:none;
}
.place-available-spaces-btn.avail-icon-hidden{
  opacity:0;
  transform:scale(.1) translateX(60px);
}
@media (prefers-reduced-motion:reduce){
  .place-available-spaces-btn{transition:none;}
  .place-input-row .field-input{transition:none;}
}
.place-available-spaces-btn svg{width:18px;height:18px;}
.place-available-spaces-btn:hover{color:var(--orange);border-color:var(--orange);}
.place-available-spaces-btn:hover .rr-eye-closed{display:none;}
.place-available-spaces-btn:hover .rr-eye-open{display:block;}
/* Camera-scan button: desktop never gets it at all (mobile-only feature,
   see place.js) -- hard display:none, independent of the find-space
   button's own collapsed/expanded state (a plain flex sibling now, not
   sharing a slot with it -- see the @media (max-width:768px) block
   further down for the mobile un-hide). */
.place-camera-scan-btn{display:none;}

/* Camera barcode-scan modal (mobile only) -- reuses the standard
   .modal-backdrop/.modal-card shell, just wider/taller for a usable
   video preview. */
.camera-scan-card{width:92vw;max-width:420px;}
.camera-scan-video-wrap{
  position:relative;
  width:100%;
  aspect-ratio:3/4;
  background:#000;
  border-radius:4px;
  overflow:hidden;
  margin:12px 0;
}
#camera-scan-video{width:100%;height:100%;object-fit:cover;display:block;transform-origin:center;}
/* Mostly a visual "aim here" guide (its exact rectangle isn't tied 1:1 to
   the real decode crop below), but no longer pure decoration either --
   once zoomed (digital fallback, place.js), decoding really is limited to
   a centered crop of the frame, same general area this box marks. */
.camera-scan-frame{
  position:absolute;
  inset:18% 8%;
  border:2px solid var(--orange);
  border-radius:6px;
  box-shadow:0 0 0 999px rgba(0,0,0,.35);
  pointer-events:none;
}
/* Real zoom control -- native range input, deliberately not custom-styled
   into a vertical slider (iOS Safari's handling of a rotated/vertical
   range input has a history of being unreliable, and Stian's testing on
   an iPhone specifically) -- a plain horizontal slider is boring but
   works everywhere the same way. */
.camera-scan-zoom-row{display:flex;align-items:center;gap:8px;margin:0 4px 8px;}
.camera-scan-zoom-row input[type="range"]{flex:1;accent-color:var(--orange);}
.camera-scan-zoom-icon{color:var(--ink-soft);font-size:16px;font-weight:600;width:14px;text-align:center;flex:none;}
.camera-scan-status{text-align:center;color:var(--ink-soft);font-size:13px;min-height:18px;}
/* A decoded barcode that doesn't resolve to a real product (Stian: a lot
   of real barcodes -- serial numbers etc. -- look EAN-shaped without
   actually being one) -- same red as the rest of the app's error states,
   scanning keeps running underneath it (see place.js's handleDecodedText). */
.camera-scan-status.camera-scan-status-err{color:var(--err);font-weight:600;}
.field-input{
  width:100%;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  padding:12px 14px;
  font-family:'IBM Plex Mono',monospace;
  font-size:14px;
  color:var(--ink);
  outline:none;
}
.field-input:focus{border-color:var(--orange);}
/* Store login specifically (before any store/brand is even known) uses
   the Inventari-blue focus highlight instead of the Power-orange one --
   per Stian, this page should read as "Inventari" branded, not "Power."
   Everything from the employee gate onward (a specific store IS known by
   then) switches back to the normal orange, same as the rest of the app
   -- toggled via auth.js adding/removing .gate-step-store-login on
   #auth-form, not a separate stylesheet. Higher specificity than the
   plain .field-input:focus rule above wins this regardless of source
   order. */
.gate-step-store-login .field-input:focus{border-color:#11181e;}
/* Buttons on the auth gate (#auth-submit/.auth-arrow-btn/.auth-back-btn/
   .employee-pick-btn) had no visible focus indicator at all when tabbed
   to with a keyboard -- the browser's default outline is dark and just
   disappears against their dark backgrounds, so tabbing+spacing through
   the form showed nothing. Explicit outline here, using :focus-visible
   (not :focus) so it only shows for keyboard navigation, not mouse
   clicks, and following the same per-step blue/orange split as the
   field-input rule above. */
#auth-form button:focus-visible{outline:2px solid var(--orange);outline-offset:2px;}
.gate-step-store-login button:focus-visible{outline-color:#11181e;}

table{width:100%;border-collapse:collapse;font-family:'IBM Plex Mono',monospace;font-size:13px;}
th{
  text-align:left;
  font-family:'Inter',sans-serif;
  font-weight:600;
  font-size:12px;
  color:var(--ink-soft);
  text-transform:uppercase;
  letter-spacing:.04em;
  padding:10px 12px;
  border-bottom:2px solid var(--line);
}
td{padding:11px 12px;border-bottom:1px solid var(--line);}
tr:hover td{background:var(--panel-2);}

.toolbar{
  display:flex;
  gap:10px;
  margin-bottom:18px;
  flex-wrap:wrap;
  align-items:center;
}

/* A fixed 560px only showed a handful of rows before scrolling was
   needed, with no visual cue there was more below it -- easy to mistake
   for "the inventory is basically empty". Scales with the viewport
   instead (leaves ~340px for header/toolbar/filters above it), same
   scrollable-so-the-page-doesn't-grow-forever behavior otherwise. */
/* No max-height/overflow-y of its own -- same nested-scroll "wheel
   latching" issue as the search page's result list had (see .result-rows'
   own comment), just on a table instead of a row list. Removing it lets
   Overview scroll as part of the page like everywhere else. Table header
   itself is plain/not sticky -- only .print-bar is (see its own rule);
   an earlier attempt at stacking the header and category-chip-rows into
   the sticky stack too got reverted per Stian, back to just the one. */

.pager{display:flex;gap:6px;margin-top:16px;flex-wrap:wrap;}
.pager-btn{
  min-width:32px;
  padding:6px 10px;
  border:1px solid var(--line);
  background:var(--white);
  border-radius:3px;
  font-family:'IBM Plex Mono',monospace;
  font-size:12px;
  cursor:pointer;
}
.pager-btn:hover{border-color:var(--orange);}
.pager-btn.active{background:var(--orange);border-color:var(--orange);color:var(--white);}
.pager-ellipsis{
  min-width:32px;
  padding:6px 4px;
  font-family:'IBM Plex Mono',monospace;
  font-size:12px;
  color:var(--ink-soft);
  display:flex;
  align-items:center;
  justify-content:center;
}
.toolbar select, .toolbar input{
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  padding:9px 12px;
  font-family:'Inter',sans-serif;
  font-size:13px;
  color:var(--ink);
}
/* display:contents on desktop -- this wrapper itself takes no space and
   isn't a flex item; its three children (.log-icon-control) just sit
   inline as ordinary toolbar items, same as #log-search. Only becomes a
   real flex row on mobile (see the 768px block), where the wrapper IS a
   toolbar item (one full-width row via .toolbar > *) and needs to split
   itself three ways. */
.log-toolbar-icons{display:contents;}
.log-icon-control{
  position:relative;
  display:inline-flex;
  align-items:center;
  gap:6px;
  padding:9px 12px;
  border:1px solid var(--line);
  border-radius:3px;
  background:var(--white);
  color:var(--ink);
  font-family:'Inter',sans-serif;
  font-size:13px;
  cursor:pointer;
  white-space:nowrap;
}
.log-icon-control:hover{border-color:var(--orange);}
.log-toolbar-icon{width:16px;height:16px;flex:none;color:var(--ink-soft);}
/* Sort direction gets its own color too (Stian: the Placed/Removed
   toggle was taking up all the visual "this icon means something"
   attention) -- red up-arrow for oldest-first, green down-arrow for
   newest-first. Reacts to the button's own data-value attribute
   directly (already flipped by log.js's click handler for the label
   text), no separate JS needed for the color or which arrow shows. */
.log-sort-toggle[data-value="oldest"] .log-toolbar-icon{color:var(--err);}
.log-sort-toggle[data-value="oldest"] .log-sort-icon-down{display:none;}
.log-sort-toggle[data-value="newest"] .log-toolbar-icon{color:var(--ok);}
.log-sort-toggle[data-value="newest"] .log-sort-icon-up{display:none;}
/* The real, fully-functional <select> -- invisible and stretched over
   the whole control so a click/tap anywhere on it opens the native
   picker, same trick as a plain <input type="file"> upload button.
   log.js keeps .log-toolbar-label's text in sync on 'change', since an
   opacity:0 select never visibly shows its own selected option. */
.log-icon-select-native{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;border:none;}
/* Two independent toggles sharing one bordered "chip" (Stian) -- same
   ok/err color language as the mobile log row's own Placed/Removed badge
   (.log-icon-plus/.log-icon-minus below), just square instead of a
   standalone circle since these sit side by side inside one control.
   Overrides .log-icon-control's own padding/gap to 0 -- the two buttons
   provide their own, .log-icon-control just contributes the shared
   outer border/radius/hover here. */
/* Vertical padding only (horizontal stays 0 -- the two buttons still
   sit flush against the group's own left/right border), so the shorter
   buttons below leave a little whitespace above/below instead of
   filling the chip edge-to-edge top and bottom. */
/* 12px left, matching the icon-side padding the other two controls get
   from their own .log-icon-control{padding:9px 12px} -- right stays 0,
   the label's own margin-left/padding-right (below) already provides
   the equivalent breathing room after the last button. */
.log-type-toggle-group{padding:4px 0 4px 12px;gap:0;overflow:hidden;}
/* The group's own padding is 0 (the two buttons fill it edge-to-edge) --
   this label needs its own left breathing room instead, same idea as
   the icon+label gap on the sort/user controls next to it. Desktop
   only: .log-toolbar-label{display:none} in the mobile media query
   already hides it there, same as those other two controls' labels. */
/* margin-left, not the group's own gap (that's 0 on purpose, so the two
   buttons stay flush against each other) -- 6px matches the icon-to-label
   gap the other two controls (sort/user) get for free from their own
   .log-icon-control{gap:6px}. */
.log-type-toggle-group .log-toolbar-label{padding-right:12px;margin-left:6px;}
.log-type-toggle{
  flex:1 1 34px;
  display:inline-flex;
  align-items:center;
  justify-content:center;
  height:26px;
  border:none;
  background:var(--white);
  color:var(--ink-soft);
  font-weight:700;
  font-size:13px;
  line-height:1;
  cursor:pointer;
}
.log-type-toggle + .log-type-toggle{border-left:1px solid var(--line);}
.log-type-toggle:hover{background:var(--panel-2);}
.log-type-toggle-place.active{background:var(--ok);color:var(--white);}
.log-type-toggle-remove.active{background:var(--err);color:var(--white);}

/* ---------- Storage map ---------- */
/* The .map-frame/.map-canvas zoom EXEMPTION (cancelling out main's
   desktop-only 1.4 zoom so the map stays physically the same size) lives
   down in the @media (min-width:769px) block near the bottom of this
   file, next to the zoom it's cancelling -- keeping the two together
   avoids exactly the bug that would happen if this rule stayed here
   un-scoped: without a matching ancestor zoom on mobile, a standalone
   zoom:1/1.4 here would actually SHRINK the map on phones instead of
   leaving it alone. */
.map-frame{
  background:var(--white);
  border:1px solid var(--line);
  border-radius:4px;
  padding:18px;
  /* No scrollbars here -- the canvas already pans/zooms internally, and a
     browser scrollbar on top of that (previously overflow:auto with a
     max-height the canvas was right up against) fought with it: once the
     canvas edged past the cap the vertical scrollbar appeared, which then
     ate into the available width and could trigger a horizontal one too.
     overflow:hidden is just a safety net -- .map-canvas below already
     clips its own pannable content, this should never actually engage. */
  overflow:hidden;
}
/* room-switcher and room-actions (+Room) sit together on the left, a
   fixed gap apart; map-toolbar-actions (Print shelf labels/Delete room)
   is pushed to the far right via its own margin-left:auto rather than a
   3-way justify-content:space-between split -- that used to make +Room
   drift left/right whenever the print button's own label changed length
   (e.g. "Print shelf labels for 3 racks"), even though the two have
   nothing to do with each other. auto-margin only eats the slack between
   room-actions and map-toolbar-actions, so the room-switcher/+Room pair
   never moves regardless of how wide the right-hand group gets. */
/* Mobile-only full-screen "modal" back button (Stian) -- see the
   @media (max-width:768px) block further down for its real appearance;
   invisible/inert here so it doesn't show up as a plain unstyled browser
   button on desktop, where Storage map stays a normal in-page panel. */
.map-mobile-back-btn{display:none;}
.map-toolbar{display:flex;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;}
.map-toolbar-actions{margin-left:auto;}
.map-toolbar-actions, .room-actions{display:flex;gap:8px;align-items:center;}
#iso-controls{display:inline-flex;gap:8px;}
.map-hint{font-family:'IBM Plex Mono',monospace;font-size:13px;color:var(--ink);margin-bottom:12px;}

/* Toolbar buttons on the Storage map page, roughly doubled in size vs the
   normal .btn.small used elsewhere (Settings, Search, etc) -- scoped here
   so it doesn't affect those other, smaller contexts. display:flex +
   centering added because a plain <button> only centers its content
   horizontally when the content's own advance-width happens to match its
   visual ink -- true for ordinary text ("3D view") but not for the
   rotate buttons' ↺/↻ glyphs (map.html/map-edit.html), whose font metrics
   leave built-in left/right whitespace that isn't equal, so the arrow
   rendered off-center within the button despite the padding itself being
   symmetric. Flexbox centers the actual rendered box instead, which
   fixes that regardless of any single glyph's own metrics -- harmless
   for the plain-text buttons, which were already centered either way. */
/* letter-spacing:normal overrides .btn's own .03em -- that's what was
   still throwing off horizontal centering even after the flexbox fix
   above: letter-spacing pads AFTER every character, including the very
   last one, so the text/glyph's own box is wider on its right side than
   its left by that trailing sliver. justify-content:center then centers
   that (slightly lopsided) box perfectly -- vertically it's invisible
   (align-items:center isn't affected by letter-spacing at all), but
   horizontally the actual ink/glyph reads as nudged left of true center.
   Removing letter-spacing here removes the asymmetry it was causing. */
.map-toolbar .btn.small{padding:14px 20px;font-size:20px;display:inline-flex;align-items:center;justify-content:center;letter-spacing:normal;}
/* Storage map's 2D/3D toggle: shows the CURRENT view's icon+label by
   default, swaps to the OTHER view's on hover (see applyModeUI in
   map.js, which builds both .vt-state spans up front) -- a plain CSS
   :hover swap rather than a JS mouseenter/leave handler, since both
   states' markup already exists in the DOM. Font-size back down from the
   map-toolbar .btn.small rule above's 20px -- fine for a bare glyph like
   the rotate arrows, but far too large next to the icon+word pairing
   here. */
#view-toggle-btn{gap:0;font-size:13px;}
#view-toggle-btn .vt-state{display:inline-flex;align-items:center;gap:6px;}
#view-toggle-btn .vt-hover{display:none;}
#view-toggle-btn:hover .vt-current{display:none;}
#view-toggle-btn:hover .vt-hover{display:inline-flex;}
#view-toggle-btn .nav-icon{width:16px;height:16px;flex-shrink:0;}
/* "Delete this room" (icon-only, no text) otherwise renders shorter than
   "Print shelf labels" next to it despite sharing the same padding --
   the icon's own line-height is shorter than the text button's, so the
   two buttons need an explicit shared height to actually match. */
.map-toolbar-actions .btn.small{height:52px;box-sizing:border-box;}

/* ---------- Room switcher ---------- */
.room-switcher{display:flex;align-items:center;gap:8px;}
.room-nav-btn{
  width:52px;height:52px;
  border:1px solid var(--line);
  background:var(--white);
  border-radius:3px;
  font-size:28px;
  line-height:1;
  cursor:pointer;
  display:flex;align-items:center;justify-content:center;
}
.room-nav-btn:hover{border-color:var(--orange);}
.room-nav-btn:disabled{opacity:.35;cursor:default;}
.room-name{
  font-family:'Oswald',sans-serif;
  font-weight:600;
  font-size:20px;
  text-transform:uppercase;
  color:var(--ink);
  min-width:90px;
  text-align:center;
  cursor:pointer;
}
/* Edit page only -- the room name is a real input instead of a display +
   dialog, so renaming is just "click in, select, retype" rather than a
   double-click-to-prompt round trip. */
.room-name-input{
  font-family:'Oswald',sans-serif;
  font-weight:600;
  font-size:20px;
  text-transform:uppercase;
  color:var(--ink);
  min-width:140px;
  text-align:center;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  padding:10px 8px;
}
.room-name-input:focus{border-color:var(--orange);outline:none;}
.room-name-input:disabled{opacity:.5;}

.map-canvas{position:relative;}
.map-canvas.mode-2d{
  background:var(--white);
  border:1px dashed var(--line);
  overflow:hidden;
  cursor:grab;
}
.map-canvas.mode-2d:active{cursor:grabbing;}
.map-canvas.mode-2d.edit-mode{cursor:copy;}
.map-canvas.mode-iso{background:var(--white);border:1px solid var(--line);overflow:hidden;}

/* The pannable/zoomable content living inside the fixed-size 2D viewport --
   background grid pattern lives here (not on .map-canvas) so it pans and
   scales along with everything else instead of staying fixed in place. */
.grid-2d-content{
  position:relative;
  transform-origin:0 0;
  background:
    linear-gradient(var(--line) 2px, transparent 2px),
    linear-gradient(90deg, var(--line) 2px, transparent 2px);
  background-size:60px 60px;
}
/* Outer "walls" of the room, distinct from (and thicker/darker than) the
   per-tile grid lines above. A ::after overlay rather than a real border --
   rack boxes are positioned absolutely against this element's own origin,
   and a real border would shift that origin inward by its width, throwing
   every rack slightly out of alignment with the grid beneath it. */
.grid-2d-content::after{
  content:'';
  position:absolute;
  inset:0;
  border:3px solid #8a8a86;
  pointer-events:none;
}
.map-empty-state{
  padding:60px 20px;
  text-align:center;
  color:var(--ink-soft);
  font-family:'IBM Plex Mono',monospace;
  font-size:13px;
}

/* 2D rack blocks -- plain white regardless of fill/occupancy (that
   gradient stays a 3D-only visualization now); the box itself rotates
   per facing (see map-edit.js/map.js), around its own center by default. */
.rack-box2d{
  position:absolute;
  background:var(--panel);
  /* Plain black, not orange -- per Stian, an unselected rack sharing the
     same orange-ish border as the .selected state (below) made the two
     hard to tell apart at a glance. Square corners too (border-radius
     dropped entirely, including the floor variant's old circular 50%)
     -- racks are rectangles, not rounded UI chips. */
  border:1px solid #000;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:2px;
  color:var(--ink);
  font-family:'IBM Plex Mono',monospace;
  font-size:11px;
  font-weight:600;
  cursor:pointer;
  user-select:none;
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.4);
}
.rack-box2d.floor{border-style:dashed;}
/* A wall is a plain blocking obstacle (pathfinding can't route through
   it), not a placement surface -- diagonal hazard stripes read as
   "obstacle" at a glance, clearly distinct from a rack's plain panel fill
   or a floor spot's dashed border. */
.rack-box2d.wall{
  background:repeating-linear-gradient(45deg, #3a3a3a, #3a3a3a 6px, #2a2a2a 6px, #2a2a2a 12px);
  color:var(--white);
}
.rack-box2d.wall .rack-code{color:var(--white);}
.map-canvas.mode-2d.edit-mode .rack-box2d{cursor:grab;}

/* ---------- Receiving zone + pathfinding debug tool ----------
   Stian's Sweep Planner pathfinding experiment (server/lib/pathfinding.js,
   map-edit.js's zoneToggleBtn/testPathfindingBtn) -- not wired into any
   real feature yet, purely a visualization for tuning the router before
   trusting it with a real walkthrough. */
#zone-toggle-btn.active{border-color:var(--orange);background:rgba(232,96,28,.12);color:var(--orange-dark);font-weight:600;}
/* Same on/off treatment for "Test pathfinding" -- it's a toggle too now
   (press again to stop), not a one-shot action. */
#test-pathfinding-btn.active{border-color:var(--info);background:rgba(47,111,168,.12);color:var(--info);font-weight:600;}
/* Blue (var(--info)) specifically because nothing else on this map uses
   it -- orange means selection, red means danger/wall-adjacent, so the
   zone marker needed its own color nothing else could be confused with. */
.receiving-zone-ball{
  position:absolute;
  width:22px;
  height:22px;
  margin-left:-11px;
  margin-top:-11px;
  border-radius:50%;
  background:var(--info);
  border:2px solid var(--white);
  box-shadow:0 0 0 2px #000;
  cursor:pointer;
  z-index:5;
}
.pathfinding-overlay{position:absolute;left:0;top:0;pointer-events:none;z-index:4;}
.pathfinding-line{fill:none;stroke:var(--info);stroke-width:3;stroke-dasharray:6 4;opacity:.85;}
/* Selected: border itself goes orange (not just an added outline ring),
   so it's an actual color change against every other rack's black
   border, not just a slightly-different shade of the same color. */
.rack-box2d.selected{border-color:var(--orange-dark);outline:2px solid var(--orange-dark);outline-offset:1px;}
.rack-code{pointer-events:none;}

/* Explicit, deliberate grid resize -- one row/column per click, in place
   of the old implicit auto-grow-while-dragging. */
.grid-edge-btn{
  position:absolute;
  width:22px;height:22px;
  border-radius:50%;
  border:1px solid var(--orange-dark);
  background:var(--white);
  color:var(--orange-dark);
  line-height:1;
  font-size:13px;
  cursor:pointer;
  padding:0;
  z-index:3;
}
.grid-edge-btn.minus{border-color:var(--ink-soft);color:var(--ink-soft);}

/* Placed next to a selected rack's empty neighbor cells, to quickly add
   a matching rack there (see renderNeighborAddButtons in map-edit.js). */
/* Wavy shimmering line + pulsing magnet between two racks that are close
   enough to be offered for gluing -- shown only while the confirm prompt
   is up (see showProximityLink/hideProximityLink in map-edit.js), not a
   persistent map decoration. Both purely decorative, no click target. */
.proximity-link-svg{position:absolute;inset:0;overflow:visible;pointer-events:none;z-index:4;}
.proximity-link-path{fill:none;stroke:#cfa90f;stroke-width:2.5;stroke-linecap:round;stroke-dasharray:7 5;animation:proximity-link-shimmer .5s linear infinite;}
@keyframes proximity-link-shimmer{to{stroke-dashoffset:-24;}}
.proximity-link-magnet{
  position:absolute;
  transform:translate(-50%,-50%);
  font-size:20px;
  pointer-events:none;
  z-index:4;
  animation:proximity-link-pulse .7s ease-in-out infinite;
}
@keyframes proximity-link-pulse{0%,100%{transform:translate(-50%,-50%) scale(1);}50%{transform:translate(-50%,-50%) scale(1.2);}}

.cell-add-btn{
  position:absolute;
  width:22px;height:22px;
  border-radius:50%;
  border:1px dashed var(--orange-dark);
  background:rgba(232,96,28,.08);
  color:var(--orange-dark);
  line-height:1;
  font-size:13px;
  cursor:pointer;
  padding:0;
  z-index:2;
}

/* The box itself rotates now (see the inline transform in map-edit.js/
   map.js), so this just sits at its top edge and rotates along with it --
   no separate rotation wrapper/per-facing classes needed any more. */
.rack-front-arrow{
  position:absolute;
  top:-6px; left:50%;
  transform:translateX(-50%);
  width:0; height:0;
  border-left:5px solid transparent;
  border-right:5px solid transparent;
  border-bottom:7px solid var(--orange-dark);
}
/* ---------- 3D (isometric) SVG view ---------- */
.iso-svg{display:block;cursor:grab;}
.iso-svg:active{cursor:grabbing;}
.rack-stack-svg polygon{transition:filter .1s ease;}
.rack-stack-svg:hover polygon{filter:brightness(1.05);}

/* Edit-map only: wraps the action panel + popover so they stack (action
   panel above, popover below, matching its width) anchored to the same
   bottom-right corner the plain popover (view-only map.js) uses on its
   own. Rotate/delete live here now, not on the rack box itself -- that's
   what stopped the button clicks getting entangled with the box's own
   drag/click handling underneath them. */
.rack-side-panels{
  position:fixed;
  bottom:54px; right:54px;
  display:flex;
  flex-direction:column;
  gap:14px;
  width:380px;
}
.rack-side-panels .shelf-popover{
  position:static;
  width:auto;
}
.rack-action-panel{
  display:flex;
  gap:14px;
  justify-content:center;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:4px;
  padding:14px;
  box-shadow:0 4px 18px rgba(0,0,0,.25);
}
.rack-action-btn{
  width:60px; height:60px;
  border-radius:8px;
  border:1px solid var(--info);
  background:var(--info);
  color:var(--white);
  font-size:26px;
  line-height:1;
  cursor:pointer;
  display:flex;
  align-items:center;
  justify-content:center;
}
.rack-action-btn.danger{border-color:var(--err);background:var(--err);color:var(--white);}
.rack-action-btn.edit{border-color:var(--edit);background:var(--edit);color:var(--white);}
/* Same yellow already used for the shelf-ID print labels -- dark icon
   (not white) for contrast against it. */
.rack-action-btn.ungroup{border-color:#cfa90f;background:#cfa90f;color:var(--ink);}
/* Hover micro-interactions, one per action -- purely decorative, each a
   small physical hint at what the button actually does. */
/* Ungroup: the middle box (the one you're about to release) lifts away
   from its two neighbors. */
.ungroup-box-mid{transition:transform .2s ease;}
.rack-action-btn.ungroup:hover .ungroup-box-mid{transform:translateY(-5px);}
/* Edit: the pen's own silhouette fills in (starts fully transparent so
   the fill itself is what animates, not a snap from "none"). */
.edit-pen-fill{fill:rgba(255,255,255,0);transition:fill .25s ease;}
.rack-action-btn.edit:hover .edit-pen-fill{fill:rgba(255,255,255,1);}
/* Rotate: a quick counterclockwise nudge and back, echoing the actual
   rotate action without fully committing to it. */
.rotate-icon{display:inline-block;}
.rack-action-btn.rotate:hover .rotate-icon{animation:rotate-icon-nudge .35s ease;}
@keyframes rotate-icon-nudge{
  0%{transform:rotate(0deg);}
  50%{transform:rotate(-18deg);}
  100%{transform:rotate(0deg);}
}
/* Delete: the lid flips open on its hinge (left edge of the top bar).
   transform-box:fill-box makes the percentage transform-origin relate to
   the lid group's own bounding box rather than the whole SVG viewport --
   more reliable across browsers than hand-picking viewBox coordinates. */
.trash-lid{transform-box:fill-box;transform-origin:0% 100%;transition:transform .2s ease;}
.rack-action-btn.danger:hover .trash-lid{transform:rotate(-25deg);}
/* Multi-select: "edit rack" stays visible (rather than disappearing) but
   greyed out and inert -- editing dimensions/story config only makes
   sense for one rack at a time, see showMultiRackSidePanels. */
.rack-action-btn:disabled{border-color:var(--ink-soft);background:var(--ink-soft);cursor:not-allowed;opacity:.7;}
.rack-action-btn .trash-icon{width:26px;height:26px;}

/* Trash icon used wherever a delete button used to be a plain "x" --
   reads as delete rather than "cancel/close" at a glance. */
.trash-icon{width:15px;height:15px;display:block;}
.btn.icon-only{display:inline-flex;align-items:center;justify-content:center;}

.shelf-popover{
  position:fixed;
  bottom:54px; right:54px;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:4px;
  padding:14px 16px;
  width:380px;
  max-height:400px;
  overflow-y:auto;
  box-shadow:0 4px 18px rgba(0,0,0,.25);
}
/* Same bottom-right spot as the real shelf popover (Stian) -- just its
   own font (this is a plain status message, not a data list like the
   popover's own monospace content) and a max-width instead of a fixed
   one, since this message is usually much shorter than a full shelf
   listing. */
.pathfinding-toast{width:auto;max-width:340px;font-family:'Inter',sans-serif;font-size:13px;line-height:1.5;}
.popover-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;font-family:'IBM Plex Mono',monospace;gap:6px;}
.popover-close{background:none;border:none;font-size:16px;cursor:pointer;color:var(--ink-soft);}
.popover-head > .popover-close{margin-left:auto;}
.popover-dims{margin:-4px 0 10px;font-family:'IBM Plex Mono',monospace;}
.popover-story{border-top:1px solid var(--line);padding:8px 0;}
.popover-story:first-of-type{border-top:none;}
.popover-story-head{display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:4px;}
/* Same yellow badge as Edit Map's .story-header-code (Stian) -- the shelf
   code used to just be a plain bold word here, easy to skim past among
   everything else in the popover; giving it the identical treatment used
   everywhere else in the app a shelf code appears makes it read as "this
   IS a shelf code" on sight, consistent with map-edit.js/search.js. */
.popover-story-code{
  font-family:'IBM Plex Mono',monospace;
  font-weight:600;
  font-size:13px;
  color:var(--ink);
  letter-spacing:.02em;
  white-space:nowrap;
  background:#cfa90f;
  border-radius:3px;
  padding:2px 7px;
}
.popover-story-open .popover-story-head{color:var(--ink-soft);font-style:italic;}
.popover-story-busy .popover-story-head{color:var(--ink-soft);}
.popover-story-busy .popover-story-head .s-sub{color:var(--err);font-weight:600;}
.popover-item{
  font-size:12px;
  color:var(--ink-soft);
  padding:2px 0 2px 8px;
  white-space:nowrap;
  overflow:hidden;
  text-overflow:ellipsis;
}

/* Add-rack modal */
.rack-kind-toggle{display:flex;gap:8px;}
.kind-btn{
  flex:1;
  padding:10px;
  border:1px solid var(--line);
  background:var(--white);
  border-radius:3px;
  cursor:pointer;
  font-family:'Inter',sans-serif;
  font-size:12px;
}
.kind-btn.active{border-color:var(--orange);background:rgba(232,96,28,.12);color:var(--orange-dark);font-weight:600;}
.dims-row{display:flex;gap:8px;}
.dims-row .field-input{width:0;flex:1;}

/* These two modals pack in a lot more (per-story rows) than the plain
   300px .modal-card default fits comfortably -- a bit of extra width
   gives the height field real breathing room instead of squeezing it
   against a fixed-width label and an "Open (walkthrough)" checkbox. */
#rack-modal .modal-card, #dims-modal .modal-card{width:380px;}

/* One row per story in the add-rack/edit-dimensions modals -- a height
   field, then a column of switches (open/busy/category-lock), so a
   walk-under gap in a shelving row (or simply two stories of different
   height, or a category earmarked to one story) can be set per story
   instead of applying to the whole rack. */
/* Replaces the gap the removed "Story height (cm) & walkthrough" label
   used to provide above the first row (Stian: the label text itself
   "made no sense" and was cut, but the spacing it happened to give still
   needs to come from somewhere). The border/background turns the whole
   list into one framed "cabinet" -- combined with the plank dividers
   between rows (below), each story reads as its own boxed compartment
   inside one continuous frame, rather than loose cards floating in the
   modal (Stian). */
#dims-story-configs, #rack-story-configs{
  margin-top:12px;
  position:relative;
  overflow:hidden;
  border-radius:6px;
  border-top:1px solid var(--white);
  border-bottom:1px solid rgba(0,0,0,.3);
  background:var(--white);
  padding:14px 21px 8px;
}
/* Left/right "posts" -- same raised-plank material (panel-2 + highlight/
   shadow edge) as the horizontal boards between stories below, just
   vertical, so the frame reads as an actual rack (uprights down both
   sides, shelf boards spanning between them) rather than a plain bordered
   box (Stian). overflow:hidden above clips their square corners to the
   container's own rounded ones. */
#dims-story-configs::before, #dims-story-configs::after,
#rack-story-configs::before, #rack-story-configs::after{
  content:'';
  position:absolute;
  top:0;
  bottom:0;
  width:7px;
  background:var(--panel-2);
  box-shadow:0 0 2px rgba(0,0,0,.15);
}
#dims-story-configs::before, #rack-story-configs::before{left:0;border-right:1px solid rgba(0,0,0,.3);}
#dims-story-configs::after, #rack-story-configs::after{right:0;border-left:1px solid var(--white);}
.story-config-row{margin-top:10px;padding-bottom:8px;}
.story-config-row:first-child{margin-top:0;}
.story-config-row:last-child{padding-bottom:0;}
/* Add/remove-story animation (Stian: "everything in the modal" should
   stretch smoothly, not just fade -- see animateStoryRowIn/Out in
   map-edit.js) -- height/margin/padding transition alongside opacity/
   transform, all inline values set and measured by JS (you can't
   transition to/from "auto" in plain CSS), this class only turns the
   transition on for exactly as long as a row is actively growing or
   shrinking. Because the row's own box is what's animating (not some
   separate visual overlay), the container/modal around it -- both plain
   auto-height -- naturally grow and shrink in step with it every frame,
   instead of jumping to the new size instantly while only the row's own
   content fades in or out separately. */
.story-row-animating{transition:opacity .22s ease, transform .22s ease, max-height .22s ease, margin-top .22s ease, padding-bottom .22s ease;}
/* A little "shelf board" between each story instead of just whitespace
   (Stian: without something reinforcing that this list IS the physical
   shelf, stacked top-to-bottom, the story numbers alone read as an
   arbitrary list for a while before it clicks). A thin raised bar with
   its own highlight/shadow edge reads as a plank with real thickness,
   not just a rule -- purely a visual cue, no click behavior of its own
   (that's still the row's own .story-header above it). */
.story-config-row + .story-config-row{margin-top:28px;position:relative;}
.story-config-row + .story-config-row::before{
  content:'';
  position:absolute;
  top:-19px;
  /* Reaches all the way out to (and a couple px past) the vertical posts
     -- the row itself sits inset by the frame's own 21px side padding,
     so a small overhang no longer reached them, leaving a visible gap of
     bare background between the horizontal board and the uprights
     (Stian: they need to actually connect). Same panel-2 material as the
     posts, so where they overlap reads as one continuous joint instead
     of two separate parts touching. */
  left:-23px;
  right:-23px;
  /* Without this, the board only overlapped the LEFT post, not the
     right one (Stian) -- the container's own ::after (the right post) is
     generated as its conceptual LAST child, so it paints ON TOP of every
     normal child that comes before it, including this board; ::before
     (the left post) is the conceptual FIRST child, so it paints
     UNDERNEATH everything, which is why only that side looked right.
     Explicitly raising the board above both (they're plain z-index:auto,
     so any positive value wins) makes the two posts behave identically. */
  z-index:1;
  height:7px;
  border-radius:1px;
  background:var(--panel-2);
  border-top:1px solid var(--white);
  border-bottom:1px solid rgba(0,0,0,.3);
  box-shadow:0 2px 2px rgba(0,0,0,.15);
}
/* The clickable header ("A8-2", or "Story 2" until the Rack ID field is
   filled in -- see updateStoryDividerLabels in map-edit.js) that doubles
   as each story's own collapse toggle -- everything below height tucks
   away by default (Stian), so which story you're looking at, and
   whether there's more to see, both read at a glance in a modal that's
   otherwise all identical-looking rows. A background + border (not just
   a bare line) so it reads as pressable, not just a label. */
.story-header{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:10px;
  padding:8px 10px;
  border-radius:4px;
  border:1px solid var(--line);
  background:rgba(0,0,0,.035);
  cursor:pointer;
}
.story-header:hover{background:rgba(0,0,0,.065);}
/* Same orange the switches themselves turn "on" (see .sweep-switch
   input:checked + .sweep-switch-track) -- border only, so a story with
   any of open/busy/category-lock switched on is spottable at a glance
   even while its own collapse is closed (Stian). Live (see
   updateHeaderActive in map-edit.js), not just the initial expanded
   state -- flips the moment a switch does. */
.story-header.active{border-color:var(--orange);}
/* Same yellow already used for the physical shelf-ID print labels and
   the ungroup button (Stian) -- reusing it here (not a new color) is
   what actually makes "this is a shelf ID" read as a consistent visual
   language across the app, not just this one modal. */
.story-header-code{
  font-family:'IBM Plex Mono',monospace;
  font-weight:600;
  font-size:13px;
  color:var(--ink);
  letter-spacing:.02em;
  white-space:nowrap;
  background:#cfa90f;
  border-radius:3px;
  padding:2px 7px;
}
.story-header-chevron{width:16px;height:16px;color:var(--ink-soft);flex-shrink:0;transition:transform .2s ease;}
.story-header[aria-expanded="true"] .story-header-chevron{transform:rotate(180deg);}
/* Height stays visible outside the collapse -- it's the one thing worth
   seeing/editing without a click, per Stian; everything else (open/busy/
   category-lock) lives in .story-collapse below. */
.story-height-row{display:flex;align-items:center;gap:8px;margin-top:8px;}
.story-height-row-label{
  font-family:'IBM Plex Mono',monospace;
  font-size:12px;
  color:var(--ink-soft);
  flex-shrink:0;
}
.story-height-input{width:0;flex:1;}
/* Same CSS-grid accordion technique as .story-category-lock-fields below
   (0fr -> 1fr, an inner wrapper doing the actual clipping) -- collapsed
   by default unless the row already has a non-default setting (see
   hasNonDefaultSettings in storyRowHtml), so existing configuration is
   never hidden the moment the modal opens, only a freshly-added or
   already-plain story starts tucked away. */
.story-collapse{
  display:grid;
  grid-template-rows:0fr;
  transition:grid-template-rows .22s ease;
}
.story-collapse.open{grid-template-rows:1fr;}
.story-collapse-inner{overflow:hidden;min-height:0;}
/* Open (walkthrough), busy (not for placing) and category-lock stack as
   one consistent column of full-width switch rows instead of the first
   two sitting side by side and the third on its own separate line below
   (Stian: inconsistent, felt like an afterthought) -- layout itself
   (display/gap/align-items/justify-content within each row) comes from
   .sweep-switch-row (map-edit.js's storyRowHtml -- reused as-is, same
   switch look as Sweep Planner's own settings toggles). */
.story-toggle-col{display:flex;flex-direction:column;gap:2px;margin-top:10px;}
.story-toggle-col .sweep-switch-label{font-size:12px;}
/* CSS-grid accordion (0fr -> 1fr) so opening/closing the category-lock
   fields animates instead of the whole block instantly appearing/
   disappearing via the `hidden` attribute (Stian: "the entire thing kind
   of suddenly shifts"). The inner wrapper is what actually clips
   (min-height:0 lets a grid track collapse all the way to 0, which a
   plain height:auto element can't animate to/from at all) and fades in
   slightly after the row itself has room, so content doesn't pop in
   before there's space for it. */
.story-category-lock-fields{
  display:grid;
  grid-template-rows:0fr;
  transition:grid-template-rows .22s ease;
}
.story-category-lock-fields.open{grid-template-rows:1fr;}
.story-category-lock-fields-inner{overflow:hidden;min-height:0;opacity:0;transition:opacity .15s ease;}
.story-category-lock-fields.open .story-category-lock-fields-inner{opacity:1;transition:opacity .2s ease .08s;}
/* Same accordion technique as .story-category-lock-fields just above --
   the "Custom dimensions" toggle's own reveal (summary text + Edit
   dimensions button, see storyRowHtml/wireStoryRow in map-edit.js). */
.story-custom-dims-fields{
  display:grid;
  grid-template-rows:0fr;
  transition:grid-template-rows .22s ease;
}
.story-custom-dims-fields.open{grid-template-rows:1fr;}
.story-custom-dims-fields-inner{overflow:hidden;min-height:0;opacity:0;transition:opacity .15s ease;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-top:8px;}
.story-custom-dims-fields.open .story-custom-dims-fields-inner{opacity:1;transition:opacity .2s ease .08s;}
/* Matches .story-toggle-col .sweep-switch-label's own font-size (12px) --
   .s-sub has no base rule of its own in this file (every other place
   that uses it sets its own font-size/color explicitly), so without this
   it was just inheriting the row's plain 13px/ink text instead of
   reading as a secondary label the way the toggle rows above it do. */
.story-custom-dims-summary{margin:0;font-size:12px;color:var(--ink-soft);}

/* Per-story dimensions modal (Stian) -- sits ON TOP of the rack-edit
   modal underneath it (#rack-modal/#dims-modal, both plain
   .modal-backdrop at z-index:50), same layering trick
   .mobile-minimap-modal already uses to sit above a regular modal. */
.shelf-dims-modal{z-index:60;}
/* Controls sit on the FRAME, not #shelf-dims-preview itself -- that
   element's innerHTML gets fully replaced every re-render (see
   renderShelfDimsPreview in map-edit.js), which would otherwise wipe the
   buttons out along with the old scene. Same relative-frame/absolute-
   controls split as Search's own .search-minimap-frame/-controls. */
.shelf-dims-preview-frame{position:relative;margin-bottom:14px;}
.shelf-dims-preview-controls{position:absolute;top:8px;right:8px;display:flex;gap:6px;}
/* A gentle pulse on the story currently being edited in that preview --
   deliberately NOT the vivid full-strength search/sweep "here it is"
   .shelf-blink (Stian: "much less green... more subtle") -- a light tint
   with a shallow opacity swing reads as "this one" without shouting. */
.shelf-dims-preview .shelf-blink-subtle{
  fill:#bfe3cb;
  animation:shelf-blink-subtle-anim 1.6s ease-in-out infinite;
}
@keyframes shelf-blink-subtle-anim{
  0%, 100% { opacity:1; }
  50% { opacity:.82; }
}
/* Include (left) and exclude (right) side by side, each with its own
   input and its own chip list directly underneath it, instead of one
   input+list, then the next input+list, stacked on top of each other --
   half the vertical footprint (Stian: the extra height was crowding the
   divider between stories, making them harder to tell apart). */
.story-category-cols{display:flex;gap:10px;margin-top:8px;}
.story-category-col{flex:1;min-width:0;display:flex;flex-direction:column;}
.story-category-col-label{margin-top:0;}
.story-category-col-label.include{color:var(--ok);}
.story-category-col-label.exclude{color:var(--err);}
.story-category-include-chips, .story-category-exclude-chips{margin-top:6px;}
/* Same colored-border + watermark-icon treatment Overview's own
   include/exclude category inputs use (#list-include-input/
   #list-exclude-input) -- a class copy, not a shared rule, since these
   are dynamically generated per story row and can't share an element id.
   Green/red alone said include vs exclude on the CHIPS already; this is
   what was missing on the INPUT itself before you'd typed anything
   (Stian: "not noticing any color or icons on the include/exclude
   inputs"). */
.story-category-include-input, .story-category-exclude-input{
  background-repeat:no-repeat;
  background-position:9px center;
  background-size:13px 13px;
  padding-left:29px;
}
.story-category-include-input{
  border-left:3px solid var(--ok);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232f8f4e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.59 13.41 11 3.83 3.83 11l9.58 9.59a2 2 0 0 0 2.83 0l4.35-4.35a2 2 0 0 0 0-2.83Z'/%3E%3Ccircle cx='7.5' cy='7.5' r='1' fill='%232f8f4e' stroke='none'/%3E%3C/svg%3E");
}
.story-category-exclude-input{
  border-left:3px solid var(--err);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23c93a3a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.59 13.41 11 3.83 3.83 11l9.58 9.59a2 2 0 0 0 2.83 0l4.35-4.35a2 2 0 0 0 0-2.83Z'/%3E%3Ccircle cx='7.5' cy='7.5' r='1' fill='%23c93a3a' stroke='none'/%3E%3C/svg%3E");
}
/* Sits directly under the width/depth row now (Stian), same gap so the
   two buttons visually line up under the two inputs above them --
   flex:1 on each button (below) is what actually matches their widths,
   this gap/margin is just keeping the rhythm consistent. */
.dims-story-actions{display:flex;gap:8px;margin-top:8px;}
/* +/- as their own small colored circle instead of plain button-label
   text (Stian: wasn't vertically centered against the label, and hard to
   see) -- inline-flex + align-items:center on the button itself is what
   actually centers the icon against the label text's own line-height,
   the icon's own centering just keeps the glyph centered within its own
   circle. flex:1 + justify-content:center splits the pair evenly across
   the same width the width/depth inputs above them span, instead of
   each button just hugging its own label text. */
.dims-story-btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;flex:1;}
.dims-story-btn-icon{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  width:16px;
  height:16px;
  border-radius:50%;
  background:var(--ok);
  color:var(--white);
  font-size:12px;
  font-weight:700;
  line-height:1;
  flex-shrink:0;
}
.dims-story-btn-icon-remove{background:var(--err);}

/* ---------- Place product ---------- */
/* Shrinks to fit the card instead of being a big box with the card
   floating in the middle of it, and centers itself in the content area. */
.place-panel{
  flex:none;
  min-height:0;
  align-self:center;
  margin-top:auto;
  margin-bottom:auto;
  width:560px;
  max-width:100%;
}
/* Same shrink-to-fit-and-center trick as .place-panel above -- Sweep
   Planner's three screens (batch/review/walkthrough) were sitting as
   full-width boxes flush against the sidebar instead of centered in the
   content area like every other single-card page. */
.sweep-panel{
  flex:none;
  min-height:0;
  min-width:0;
  align-self:center;
  margin-top:auto;
  margin-bottom:auto;
  width:560px;
  max-width:100%;
}
.place-card{
  max-width:560px;
  width:100%;
  min-width:0;
  display:flex;
  flex-direction:column;
  gap:16px;
}
/* Kept out of view without display:none (which would make it
   unfocusable) -- see place.js's mobile branch. */
.scan-catcher{
  position:fixed;
  top:-1000px;
  left:-1000px;
  width:1px;
  height:1px;
  opacity:0;
  border:none;
  padding:0;
}
.place-msg{
  font-family:'IBM Plex Mono',monospace;
  font-size:13px;
  padding:12px 14px;
  border-radius:3px;
  background:rgba(47,143,78,.12);
  border:1px solid rgba(47,143,78,.35);
  color:var(--ok);
}
.place-msg-err{
  background:rgba(201,58,58,.12);
  border-color:rgba(201,58,58,.35);
  color:var(--err);
}
.place-msg-warn{
  background:rgba(232,96,28,.12);
  border-color:rgba(232,96,28,.35);
  color:var(--orange-dark);
}
.place-msg a{color:inherit;font-weight:600;}
.place-success-text{margin-bottom:8px;}
.place-success-actions{display:flex;gap:8px;justify-content:center;}
/* Fixed 44px tall, padding widened +5px per side (+10px total) versus
   the normal .btn.small (7px 10px) they'd otherwise inherit -- desktop
   only, see the mobile media query below which resets these back down.
   Selector needs the extra ".btn.small" specificity to actually beat the
   plain .btn.small rule further down the file (same specificity would
   otherwise lose on source order alone). */
.place-success-actions .btn.small{
  height:44px;
  padding:0 15px;
  display:inline-flex;
  align-items:center;
  justify-content:center;
}
.place-cancel-btn{background:var(--err);border-color:var(--err);color:var(--white);}
.place-cancel-btn:hover{background:#a92f2f;border-color:#a92f2f;}
/* Ghost/outline instead of a solid fill -- Cancel stays a strong solid
   red since undoing a placement is the one decisive action here, but
   Add tag is optional/secondary and shouldn't carry the same visual
   weight as a same-size solid-filled button right next to it. */
/* Needs the full "border" shorthand, not just border-color -- .btn's own
   base rule sets border:none (style AND width, not just color), so a
   border-color-only override here had nothing to actually render. */
.place-tag-btn{background:transparent;border:1px solid var(--info);color:var(--info);}
.place-tag-btn:hover{background:rgba(47,111,168,.1);}
/* Rainbow outline instead of a plain solid border -- a color-bar test
   pattern is exactly what a TV calibration screen looks like, and it
   reads as a distinct, memorable button at a glance instead of just
   another dark button that looks like the nav sidebar. Standard
   gradient-border trick: two background layers, one solid (clipped to
   the padding box, i.e. everything inside the border) and one rainbow
   (clipped to the border box, i.e. only the border ring itself) --
   needs a real transparent border for background-clip to have a ring to
   clip the second layer into. */
.place-tag-preset-calibrated{
  color:var(--white);
  border:3px solid transparent;
  background-color:var(--ink);
  background-image:linear-gradient(var(--ink), var(--ink)), linear-gradient(90deg, #ff3b30, #ff9500, #ffcc00, #34c759, #007aff, #af52de);
  background-origin:border-box;
  background-clip:padding-box, border-box;
}
.place-tag-preset-calibrated:hover{
  background-image:linear-gradient(#000, #000), linear-gradient(90deg, #ff3b30, #ff9500, #ffcc00, #34c759, #007aff, #af52de);
}
.place-tag-preset-outlet{background:#e8c11c;border:2px solid var(--ink);color:var(--ink);}
.place-tag-preset-outlet:hover{background:#cfa90f;}
/* Dark red, deliberately a shade darker than --err (the delete/cancel
   red) so a "Service" tag never reads as a destructive action button
   sitting next to Undo placement. */
.place-tag-preset-service{background:#7a2020;border:2px solid var(--ink);color:var(--white);}
.place-tag-preset-service:hover{background:#5c1818;}
.place-tag-picker{
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  gap:8px;
  margin-top:10px;
  padding-top:10px;
  border-top:1px solid rgba(47,143,78,.35);
}
.place-tag-options, .place-tag-custom-list{display:flex;flex-wrap:wrap;gap:8px;}
/* Tapping a preset (or adding a custom tag) only stages it -- nothing is
   saved until "Add tag(s)" -- so the blue outline is what shows "this one
   is currently selected", same blue as the rest of the tag theme. */
.place-tag-option.selected{outline:3px solid var(--info);outline-offset:2px;}
.place-tag-confirm{background:var(--info);border-color:var(--info);color:var(--white);width:100%;}
.place-tag-confirm:hover{background:#255a87;}
/* Pushed to the far right of the picker row, away from the preset
   buttons, per Stian's request to visually separate "pick a preset" from
   "type your own". */
.place-tag-other-group{display:flex;align-items:center;gap:8px;margin-left:auto;}
.place-tag-other{
  width:120px;
  font-family:'IBM Plex Mono',monospace;
  font-size:12px;
  padding:7px 9px;
}

.suggestions-list{
  display:flex;
  flex-direction:column;
  gap:6px;
  margin-top:8px;
}
.suggestion-item{
  display:flex;
  justify-content:space-between;
  align-items:center;
  gap:10px;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  padding:9px 12px;
  font-family:'Inter',sans-serif;
  font-size:13px;
  text-align:left;
  cursor:pointer;
}
.suggestion-item:hover{border-color:var(--orange);}

.search-field-row{display:flex;align-items:center;gap:10px;margin-bottom:16px;}
.search-field-row .field-label{margin:0;white-space:nowrap;}
.search-field-row .field-input{flex:1;}
/* Plain black magnifying glass instead of the "Search here:" text label
   (Stian) -- cursor:pointer + the <label for> association still focuses
   the input on click, same as the text version did. */
.search-field-icon-label{cursor:pointer;color:var(--ink);}
.search-field-icon-label svg{width:20px;height:20px;display:block;}

/* A scaled-down live snapshot of the storage map (see IsoRender in
   assets/iso-render.js, used by search.js) -- hidden until an actual
   search has been run, same reveal timing as the result rows. Hovering a
   result row pans this to center that shelf's rack (IsoRender.panTo) and
   blinks the matching shelf face green (.shelf-blink below); every shelf
   currently in the results gets a persistent green outline
   (.shelf-result-highlight). Tall enough that the auto-fit scene (see
   buildScene's fit-to-content math) reads clearly rather than as a thin
   sliver. */
/* position:sticky -- once the page scrolls far enough that the minimap's
   natural spot would scroll out of view (e.g. hovering a result row near
   the bottom of a long list), it pins near the top of the viewport
   instead of disappearing, so the highlighted shelf it's pointing at
   stays visible the whole time you're scrolling through results. Stops
   "following" on its own once you scroll past the bottom of the results
   panel entirely (sticky is bounded by its own containing block) --
   exactly the behavior wanted, no extra JS needed. z-index keeps it
   drawn above the result rows scrolling past underneath it once stuck. */
/* top:0, not top:16 -- once .content-panel's own top edge (with the
   search field above this) has scrolled well past the viewport, a
   nonzero offset here left an uncovered strip at the very top of the
   screen (nothing else paints that region once the panel's real
   boundary is long gone), showing the page background through. top:0
   only affects the STUCK state (a sticky offset does nothing before an
   element would otherwise scroll past it), so this doesn't change
   anything about the normal, unstuck layout below the search field. */
/* padding-top (not the old top:16 offset, see the comment above) gives
   the pinned white box a bit of breathing room above once stuck, matching
   how it naturally had room above it before scrolling started (from
   .search-field-row's own margin, which disappears from view once this
   is pinned flush at the very top) -- safe against the earlier
   see-through bug specifically because this padding sits INSIDE a box
   that has its own background painted behind it, not in genuinely empty
   offset space the way top:16 was. No padding-bottom -- read as an
   unexplained gray patch rather than intentional spacing, per Stian. */
.search-minimap-frame{position:sticky;top:0;z-index:5;margin-bottom:16px;background:var(--panel);padding-top:12px;height:0;overflow:hidden;transition:height .35s ease;}
.search-minimap-controls{position:absolute;top:20px;right:8px;display:flex;gap:6px;}
.search-minimap{
  /* Permanently the "compact" size, not just once scrolled -- per Stian,
     simpler than the scroll-triggered shrink/glide this used to be.
     min() against 100vh so a genuinely short viewport can still shrink
     further to leave room for ~5 result rows below it; a typical monitor
     just gets the flat 220px. */
  height:min(220px, calc(100vh - 300px));
  background:var(--white);
  border:1.5px solid var(--line);
  border-radius:4px;
  overflow:hidden;
  position:relative;
  /* Drag-to-pan (iso-render.js's wireDragPan) never calls preventDefault
     on its own pointer events, since on desktop there's no default touch
     gesture to fight. On a touchscreen -- specifically the mobile modal,
     see mobile-minimap-modal-panel below -- that let the SAME drag also
     scroll the page underneath (Stian: "panning the minimap on the modal
     also moves the background"), because nothing told the browser this
     element already owns touch drags. touch-action:none opts it out of
     the browser's own touch scrolling/zooming so only the pan logic
     responds to the gesture. */
  touch-action:none;
  /* A permanent shadow (not just once it's actually stuck -- pure CSS has
     no reliable way to detect "am I currently pinned" cheaply, so this
     just always reads as a floating card) plus a brand-colored glow on
     hover -- per Stian, the plain sticky box read as an unintentional
     glitch to a new user rather than a deliberately pinned panel. Hover
     doubles as a hint that this thing is actually interactive (drag to
     pan, scroll to zoom), which wasn't obvious either. Border/shadow
     strengthened (Stian) once the sticky top offset grew to 128px --
     wanted the card to read more clearly as "floating" above the page at
     that distance from the edge. */
  box-shadow:0 6px 20px rgba(0,0,0,.25);
  transition:border-color .2s ease, box-shadow .2s ease, transform .15s ease;
}
.search-minimap:hover{
  border-color:var(--orange);
  box-shadow:0 4px 16px rgba(232,96,28,.3);
  transform:translateY(-1px);
}
/* Room-name flash (Stian): whenever the minimap actually switches which
   room it's showing (hovering/focusing a result that lives elsewhere),
   the room's name blinks in the top-left corner -- same face/size/case as
   .page-title, but in the same dark gray the nav buttons and page
   background use (var(--navy-2)) rather than page-title's own white,
   since this sits on the minimap's light background, not the dark page
   background .page-title is normally read against. Lives inside
   #search-minimap itself (not the frame around it) so it comes along for
   free when that node gets moved into the mobile modal -- one label,
   works in both places. opacity:0 at rest -- .blink-in (JS re-adds it
   fresh each real room switch, see rebuildMinimapScene) is what actually
   plays the animation; without a class to retrigger, a plain `animation`
   here would only ever play once, on page load. */
.minimap-room-label{
  position:absolute;
  top:8px;
  left:12px;
  z-index:2;
  margin:0;
  font-family:'Oswald',sans-serif;
  font-weight:600;
  font-size:26px;
  text-transform:uppercase;
  color:var(--navy-2);
  opacity:0;
  pointer-events:none;
}
.minimap-room-label.blink-in{
  animation:minimap-room-label-fade 1.6s ease-in-out;
}
/* Fade in, hold a beat, fade out (Stian) -- no blinking. */
@keyframes minimap-room-label-fade{
  0%, 100%{opacity:0;}
  19%, 81%{opacity:1;}
}
@media (prefers-reduced-motion:reduce){
  .minimap-room-label.blink-in{animation:none;}
}
/* Transitions on .iso-viewport's transform are applied inline from JS
   now (IsoRender.panTo/resetPan set a .5s glide, the wheel-zoom handler
   explicitly sets 'none' per tick) rather than a blanket CSS rule --
   wheel-zooming needs to feel instant, not animate every scroll tick. */
/* Promotes the viewport group to its own compositor layer so panning/
   zooming moves it wholesale instead of the browser repainting every
   polygon inside it each frame -- SVG doesn't get this for free the way
   HTML/CSS transforms do. Matters most for a big room with lots of racks;
   see also the hover-intent delay in search.js, which cuts down how often
   this animation even fires. */
.search-minimap .iso-viewport{will-change:transform;}
/* IsoRender.panTo's hover-zoom freeze (see beginZoomFreeze in
   iso-render.js) -- a plain bitmap snapshot laid over the live scene so
   the browser can animate a cheap GPU bitmap scale instead of
   re-rasterizing the real vector content every frame of the zoom-in
   glide. pointer-events:none so it stays purely visual -- hover/drag on
   the (briefly static) real scene underneath still work exactly as
   before, the freeze doesn't intercept them. will-change promotes it to
   its own layer for the same reason .iso-viewport gets one above. */
.search-minimap .iso-zoom-freeze{pointer-events:none;will-change:transform;}

/* Green outline around every shelf face currently in the search results
   -- overrides the polygon's own inline stroke/fill presentation
   attributes (any CSS rule, even a class selector, beats those). */
.search-minimap .shelf-result-highlight{
  stroke:var(--ok) !important;
  stroke-width:2.5px;
}
/* Slow green blink on the shelf whose result row is currently hovered --
   replaces the fill outright (not blended with the original occupancy
   color) so it reads unambiguously as "this one" against the rest of
   the map. Fill is set once, statically, to solid green; only OPACITY is
   animated -- fill is a paint property (repaints the actual shape every
   frame for as long as the animation runs), opacity is compositor-only,
   so this reads the same but costs far less on weaker hardware,
   especially while a row stays hovered for a while. */
/* .mobile-minimap-view alongside .search-minimap -- search's own mobile
   modal moves the SAME .search-minimap-classed node into that container
   (still matches the first selector), but Sweep Planner's shelf modal
   (sweep.js's openSweepShelfModal) builds a fresh IsoRender scene straight
   into #sweep-shelf-modal-minimap with no .search-minimap wrapper at all,
   so it only ever matches the second. */
.search-minimap .shelf-blink, .mobile-minimap-view .shelf-blink{
  fill:var(--ok);
  animation:shelf-blink-anim 1.3s ease-in-out infinite;
  will-change:opacity;
}
@keyframes shelf-blink-anim{
  0%, 100% { opacity:1; }
  50% { opacity:.55; }
}
/* Dims every OTHER rack (or, for a glued row, everything outside that
   whole group) while a result row is hovered, so the blinking target
   stays visible even when another rack sits in front of it in the iso
   projection -- opacity transition is fine here (unlike the pan/zoom
   transform above) since it only ever fires on hover, not continuously. */
.search-minimap .rack-stack-svg, .mobile-minimap-view .rack-stack-svg{transition:opacity .3s ease;will-change:opacity;}
.search-minimap .rack-dimmed, .mobile-minimap-view .rack-dimmed{opacity:.1;}
/* Same effect on the Storage map page's own interactive 3D view --
   hovering a rack there fades every other rack (outside its glued group)
   the same way. */
.mode-iso .rack-stack-svg{transition:opacity .3s ease;}
.mode-iso .rack-dimmed{opacity:.1;}

/* ---------- Search result rows ---------- */
/* No max-height/overflow-y of its own (used to be capped at 640px with
   its own internal scrollbar) -- a nested scrollable region like that
   triggers the browser's wheel-event "scroll latching": once a scroll
   gesture starts inside it, every following wheel tick keeps going to
   THIS element even after it can't scroll any further, only handing off
   to the page's own scroll once the gesture times out or the cursor
   moves enough to force a fresh hit-test. That's what read as "stuck"
   scrolling at the bottom of a long results list. Removing the nested
   scroll boundary entirely removes the thing that was getting latched --
   results now just extend the page's own scroll instead, same as every
   other page in the app already works (Overview has this exact same
   nested-scroll pattern and the exact same symptom, confirming this
   isn't specific to anything search.js does). */
.result-rows{display:flex;flex-direction:column;gap:8px;}
/* Fades + slides in on arrival (Stian: results should animate in the
   same way "Remove from shelf" animates them out) -- a plain `animation`,
   not the shrink/grow height trick .result-row-leaving below uses,
   because renderResults() rebuilds every row on the page at once (a
   search keystroke, a page change), not one row appearing next to others
   that are already settled -- there's no existing gap for it to grow
   into, so there's nothing height-wise to animate FROM. Fresh elements
   every render (resultsEl.innerHTML = ...) is what makes a plain always-
   on `animation` property here replay automatically on its own, same
   reasoning as the page-nav bounce above. */
@keyframes result-row-in{
  0%{opacity:0;transform:translateY(-8px);}
  100%{opacity:1;transform:translateY(0);}
}
.result-row{
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  padding:10px 14px;
  display:flex;
  align-items:center;
  gap:14px;
  transition:border-color .15s;
  position:relative;
  animation:result-row-in .22s ease-out;
}
@media (prefers-reduced-motion:reduce){
  .result-row{animation:none;}
}
.result-row:hover{border-color:var(--orange);}
/* "Remove from shelf" fold-away (search.js's animateRowRemoval) -- same
   technique as Edit rack's add/remove-story rows (style.css's
   .story-row-animating), just its own class since this row's base
   .result-row rule already owns a `transition` for something else
   (border-color on hover) that shouldn't run for anywhere near this
   long. */
.result-row-leaving{transition:max-height .22s ease, padding-top .22s ease, padding-bottom .22s ease, margin-top .22s ease, margin-bottom .22s ease, opacity .2s ease, transform .2s ease;}
.rr-cell{
  flex:1;
  min-width:0;
  overflow:hidden;
  text-overflow:ellipsis;
  white-space:nowrap;
  font-size:13px;
}
/* The tag badge lives INSIDE whichever cell search.js decides to put it in
   (see renderResults) rather than as its own row-level flex item, so its
   presence/absence never shifts any of the OTHER columns -- only that
   cell's own internal layout changes. On mobile that's the brand cell
   (rows wrap onto their own line there, and pairing the tag with model
   name left too little room for the model text, cutting it off); on
   desktop -- per Stian, tag-next-to-brand didn't make sense there -- it's
   back to sitting with the model name instead, both cells set up
   identically to host it either way. Each cell's own text truncates on
   its own; the tag stays fixed-size next to it via flex:none below. */
.rr-brand{font-weight:600;flex:0.8;display:flex;align-items:center;gap:6px;}
/* Pushed to the far right of the brand cell (Stian) -- brand-text stays
   left-anchored and keeps first claim on the cell's width (via its own
   min-width:0, letting it truncate before the tags ever have to shrink),
   the tag group takes whatever's left on the right rather than sitting
   right up against the text. */
.rr-brand .rr-tag-group{margin-left:auto;flex:none;flex-wrap:nowrap;}
.rr-model{display:flex;align-items:center;gap:6px;}
.rr-brand-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}
.rr-model-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}
.rr-ean{font-family:'IBM Plex Mono',monospace;color:var(--ink-soft);}
.rr-room{flex:0.8;color:var(--ink-soft);}
/* Eye icon, left of the shelf ID -- deliberately the ONLY thing that
   triggers the minimap pan/zoom/blink now (see search.js), not hovering
   the row itself, since a full-width row was too easy to sweep across by
   accident. Closed by default, opens on hover as the "yes, this is the
   thing to hover" affordance -- two stacked SVGs, CSS swaps which one
   shows rather than redrawing a path, so the swap itself costs nothing
   during a hover. Desktop-only (see the 1440px compact-layout override
   below) since the minimap it controls is hidden there too. */
.rr-view-icon{
  flex:none;
  display:flex;
  align-items:center;
  justify-content:center;
  width:20px;
  height:20px;
  color:var(--ink-soft);
  cursor:pointer;
}
.rr-view-icon:hover{color:var(--orange);}
.rr-view-icon svg{width:18px;height:18px;}
.rr-eye-open{display:none;}
.rr-view-icon:hover .rr-eye-closed{display:none;}
.rr-view-icon:hover .rr-eye-open{display:block;}
/* Same yellow badge used everywhere else a shelf code appears now (Edit
   Map's rack editor, the Storage Map popover, Sweep Planner's step cards
   -- see .story-header-code/.popover-story-code/.sweep-review-shelf-badge) --
   was a plain gray badge, per Stian's own "this should maybe be added on
   search results as well?" while doing the others. */
.rr-shelf{
  flex:none;
  font-family:'IBM Plex Mono',monospace;
  font-weight:600;
  font-size:13px;
  letter-spacing:.02em;
  color:var(--ink);
  background:#cfa90f;
  padding:4px 9px;
  border-radius:3px;
}
/* Blinks in sync with the minimap's own shelf-blink (same rhythm, same
   shared keyframe -- see search.js, which toggles this class on the row's
   .rr-shelf badge at the exact same moments it toggles the minimap SVG
   face's) -- only ever triggered by hovering .rr-view-icon, which is
   desktop-only, so no extra media-query scoping needed here. */
.rr-shelf.shelf-blink{
  background:var(--ok);
  color:var(--white);
  animation:shelf-blink-anim 1.3s ease-in-out infinite;
}
.rr-tag{
  flex:none;
  font-size:11px;
  font-weight:600;
  text-transform:uppercase;
  letter-spacing:.02em;
  color:var(--info);
  border:1px solid var(--info);
  padding:3px 8px;
  border-radius:10px;
}
/* Same size/shape as the plain custom-tag look above, just a different
   background+font color per tag type (Stian: "easier to distinguish") --
   a custom tag (no dedicated class, .rr-tag's own default) stays the
   original blue outline. Each predetermined tag gets its own distinct
   look now (Stian), matching place.js's own picker button styling for
   the same tag names where it already existed (Outlet's yellow,
   Calibrated's rainbow border) rather than inventing a separate palette. */
.rr-tag.rr-tag-outlet{background:#e8c11c;border-color:var(--ink);color:var(--ink);}
/* Deliberately a shade darker than --err (the delete/cancel red) so it
   never reads as a warning. */
.rr-tag.rr-tag-service{background:#7a2020;border-color:#7a2020;color:var(--white);}
/* Same rainbow-gradient-border trick as .place-tag-preset-calibrated in
   place.js (a TV calibration screen's own color-bar test pattern) --
   two background layers, one solid (clipped to the padding box) and one
   rainbow (clipped to the border box), needing a real transparent border
   for background-clip to have a ring to clip the second layer into. */
.rr-tag.rr-tag-calibrated{
  color:var(--white);
  border:2px solid transparent;
  background-color:var(--ink);
  background-image:linear-gradient(var(--ink), var(--ink)), linear-gradient(90deg, #ff3b30, #ff9500, #ffcc00, #34c759, #007aff, #af52de);
  background-origin:border-box;
  background-clip:padding-box, border-box;
}
/* Wraps one or more .rr-tag chips (or the overflow icon) as a single
   unit -- lets search.js's corner positioning / overview's left-margin
   spacing target ONE element regardless of how many tags are inside. */
.rr-tag-group{display:inline-flex;flex-wrap:wrap;gap:4px;align-items:center;}
/* Collapsed form once there are more tags than comfortably fit inline
   (see TagUtil.tagBadgesHtml) -- same footprint as a single .rr-tag chip,
   opens a small modal listing every tag on click. */
.tag-overflow-btn{
  display:inline-flex;align-items:center;gap:3px;
  flex:none;
  font-size:11px;font-weight:600;
  color:var(--info);border:1px solid var(--info);background:transparent;
  padding:3px 7px;border-radius:10px;cursor:pointer;
}
.tag-overflow-btn:hover{background:rgba(47,111,168,.1);}
.tag-overflow-btn svg{width:12px;height:12px;}
/* Overview's list table isn't a flex row (unlike search's .rr-model), so
   the tag needs its own left margin instead of relying on a flex gap. */
.list-tag{margin-left:6px;}
/* Same reasoning as Search's .rr-brand .rr-tag-group -- structurally
   forbidden from wrapping its own chips onto multiple lines, so there's
   no in-between "half-broken" state for applyProductCellTagOverflow
   (list.js) to ever have to catch mid-transition. */
.list-cell-product .rr-tag-group, .log-cell-product .rr-tag-group, .list-mobile-card-title .rr-tag-group{flex-wrap:nowrap;}
/* Hidden by default -- only shown by the @container tier further down,
   once the title itself has been dropped in favor of this shorter
   "model_name – leaf category" stand-in (Stian: keep the tag pill from
   getting squeezed out by a long title before then). */
.list-row-fallback{display:none;}
.rr-remove{flex:none;}
.rr-remove-wrap{flex:none;}
.rr-remove-countdown{
  position:relative;
  display:flex;
  align-items:center;
  gap:6px;
  background:var(--panel-2);
  border:1px solid var(--line);
  border-radius:3px;
  padding:4px 6px 4px 10px;
  overflow:hidden;
  /* Overridden inline (search.js) per row to match its actual remaining
     seconds -- a resumed countdown (e.g. after navigating away and back)
     can start at less than the full 5s, and the fuse below needs to stay
     in sync with the real timer, not always assume a fresh 5s run. */
  --fuse-duration:5s;
}
/* A "burning fuse" -- a thin red bar along the bottom edge that shrinks
   from full width to nothing over the countdown, so it's completely gone
   right as the timer hits 0. transform:scaleX (not animating width
   directly) since that's compositor-only, cheap on weak hardware same as
   everything else in this app's animations. A plain border can't shrink
   in length on its own (border-width only changes thickness, uniformly
   on all sides) -- this bar is the practical equivalent, kept simple and
   broadly compatible on purpose (unlike a conic-gradient ring, which
   would need @property to animate smoothly and isn't safe to assume
   works on the TC scanner's WebView, same open question as :has()
   above). */
.rr-remove-countdown::after{
  content:'';
  position:absolute;
  left:0; right:0; bottom:0;
  height:3px;
  background:var(--err);
  transform-origin:left;
  animation:fuse-burn linear forwards;
  animation-duration:var(--fuse-duration);
}
@keyframes fuse-burn{
  from{transform:scaleX(1);}
  to{transform:scaleX(0);}
}
.rr-remove-countdown-text{font-size:12px;color:var(--ink-soft);white-space:nowrap;}
/* Was a bare "✕" in a 22px circle -- title="Cancel removal" gave it an
   accessible name, but nothing readable at a glance under time pressure
   (Stian's audit: a scan-and-glance UI needs visible text, not just a
   tooltip nobody hovers on a touchscreen). Widened from a circle to a
   pill so "✕ Cancel" actually fits. */
.rr-remove-cancel{
  flex:none;
  height:22px;
  padding:0 8px;
  border-radius:11px;
  border:1px solid var(--err);
  background:var(--white);
  color:var(--err);
  font-size:11px;
  font-weight:600;
  line-height:1;
  white-space:nowrap;
  cursor:pointer;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:3px;
}
.rr-remove-cancel:hover{background:var(--err);color:var(--white);}
.rr-thumb{
  flex:none;
  width:52px;
  height:52px;
  border-radius:4px;
  object-fit:cover;
  background:var(--panel-2);
  border:1px solid var(--line);
}
/* A real product photo is roughly square with the subject centered, so
   object-fit:cover (fill the frame, crop the overflow) works fine -- a
   brand's wordmark logo is usually wide and short instead, and cover on
   that just zooms into a random slice of it (Stian: "I can only see the
   part of an H" on Liebherr's logo). data-stage="1" is exactly "showing
   the brand-logo fallback, not the product's own photo" (see
   script.js/search.js), so this only swaps modes for that case --
   contain scales the WHOLE logo down to fit inside the frame instead of
   cropping it, with a little padding so it doesn't touch the edges. A
   plain white backing (not the usual gray panel-2) since most logo
   assets are transparent PNGs designed to sit on white/light UI. */
.rr-thumb[data-stage="1"]{object-fit:contain;padding:5px;background:var(--white);}
.rr-thumb-empty{width:52px;height:52px;border-radius:4px;background:var(--panel-2);border:1px solid var(--line);}
/* Third fallback tier below the product's own image and the brand logo
   (Stian) -- same gray box as .rr-thumb-empty, just with a generic icon
   representing roughly what the product is (see script.js's
   categoryIconSvg) instead of being blank. */
.rr-thumb-icon{width:52px;height:52px;border-radius:4px;background:var(--panel-2);border:1px solid var(--line);display:flex;align-items:center;justify-content:center;color:var(--ink-soft);}
.rr-thumb-icon svg{width:26px;height:26px;}

/* Search has its own mobile/desktop split, deliberately NOT the shared
   768px one every other mobile rule in this file uses (see the @media
   (max-width:768px) block further down) -- per Stian, search's mobile
   layout should hold on longer than the typical mobile-vs-desktop split,
   up to 990px, since the minimap + multi-column cards need more room
   than that to be worth showing side by side. The actual switch lives in
   the @media (min-width:991px) blocks further below (display:none here
   is the "before that" state); the JS-side equivalent (whether to even
   load the minimap at all) lives in search.js as its own matchMedia
   check at the same 990px value, not the shared isMobileViewport() --
   that helper stays pinned to 768px for every OTHER page. This 1440px
   query is now just a leftover upper bound and always matches whenever
   the 991px one below hasn't already overridden it. */
@media (max-width:1440px){
  .search-minimap-frame{display:none !important;}
  .rr-view-icon{display:none !important;}
}
/* Card-style result rows (Stian, 2026-08-20): used to only kick in below
   1440px, with a single-line-per-row, ellipsis-truncated layout above
   that. Now unconditional -- per Stian, the card layout reads better at
   every width and stops long brand/model/category text getting cut off
   with "..." even when there's plenty of room. */
.result-row{flex-wrap:wrap;}
.rr-cell{flex:1 1 45%;white-space:normal;}
.rr-thumb, .rr-thumb-empty, .rr-thumb-icon{order:-1;}
/* The eye icon, shelf-ID badge, and "Remove from shelf" button now share
   ONE wrapper element (Stian, 2026-08-20) instead of being three separate
   flex items lined up via `order` -- `order` only controls sequence, not
   which line wrapping puts something on, so at some card widths the icon
   would end up alone on the line above instead of beside the shelf ID
   (the icon's fixed pixel size vs the other cells' percentage basis made
   how much room was left highly width-dependent). Wrapping them together
   makes the trio one atomic flex item: it moves to a new line as a whole,
   never splits, at any width. Same 45% basis as every other card cell.
   Mobile never shows the eye icon (see the max-width:1440px block above),
   so there this wrapper is effectively just shelf ID + Remove, same as
   before. */
.rr-shelf-actions{flex:1 1 45%;order:98;display:flex;align-items:center;gap:8px;}
/* Both need an explicit rule scoped to this wrapper (descendant selector,
   so it beats the generic .rr-cell{flex:1 1 45%} card rule above on
   specificity regardless of source order) -- without it, .rr-shelf's own
   flex:none got silently overridden by that generic rule (same specificity,
   later in the file), making the badge claim ~45% of the wrapper as its
   basis and squeezing Remove down to whatever was left, which is what
   made it wrap onto a tall 2-line button as the card narrowed. flex-basis
   0% on both makes this a pure proportional split of the wrapper's width
   (scales continuously with card width, no fixed px cliff) -- ratio is a
   starting point, tune with Stian. */
.rr-shelf-actions .rr-shelf{flex:3 1 0%;text-align:center;}
.rr-shelf-actions .rr-remove-wrap{flex:7 1 0%;}
.rr-remove, .rr-remove-countdown{width:100%;justify-content:center;}
/* The active countdown (text + cancel button) is wider than the plain
   "Remove from shelf" button it replaces -- per Stian's report from the
   TC scanner, it no longer fit in the same slot beside the shelf-ID
   badge. Rather than make room by wrapping, the shelf-ID cell hides for
   as long as the countdown is running and the countdown (flex:1 already,
   see .rr-shelf-actions .rr-remove-wrap above) simply expands into the
   space that frees up -- the shelf code is already implied by which row
   this is, so losing it briefly during the countdown costs nothing. */
.result-row:has(.rr-remove-countdown) .rr-shelf{display:none;}

/* Search desktop layout experiment (Stian, 2026-08-20): minimap sits
   beside the result list instead of above it, on any desktop width down
   to SEARCH_STACK_BREAKPOINT (1345px, found by testing together --
   narrower than that, the card layout itself started reflowing oddly, so
   rather than chase that, it just drops back to sitting on top, full
   width, same as the old single-column stack). Search's own mobile
   threshold is 990px (Stian), not the shared 768px every other page
   uses -- deliberately wider, since the minimap + card layout need more
   room to be worth it. min-width:991px is the desktop side of that split;
   the max-width:1440px block above still hides the minimap entirely
   below it. !important beats that block's own !important the same way
   source order normally would, since this block comes after it in the
   file.
   Cards cap at 585px wide (Stian) -- past that they don't need to get any
   wider, so the results column stops growing there and the minimap (flex:1)
   absorbs whatever extra width the window has instead. Keep this in sync
   with search.js's own matchMedia check (isCompactSearchLayout, also
   990px) -- that's what decides whether to even fetch/build the minimap
   at all; this block only repositions it once it exists. */
@media (min-width:991px){
  /* :not([hidden]), not a blanket display:block -- search.js still sets
     the `hidden` attribute on this element when there's no search yet or
     nothing matched (see updatePanelExpansion), same as it always did.
     A blanket !important override ignored that entirely, showing an
     empty minimap before you'd typed anything. */
  .search-minimap-frame{display:block !important;}
  .rr-view-icon{display:flex !important;}
  .search-layout{display:flex;gap:16px;align-items:flex-start;}
  /* The minimap's own base height (220px flat) predates it having a
     flexible width -- now that it can grow quite wide on a big monitor
     (search-minimap-frame's flex:1 above), a flat height read as
     needlessly squat there. Scales with viewport width instead (18vw),
     floored at the original 220px so a normal/small monitor looks exactly
     like before, ceilinged at 420px so it doesn't get silly on a huge
     one. Still wrapped in the original min() against 100vh -- a short
     viewport can still shrink it further to leave room for result rows,
     same safety net as before, just layered under the new scaling. */
  .search-minimap{height:min(calc(100vh - 300px), clamp(220px, 18vw, 420px));}
  /* Results first, minimap second (Stian) -- the search field sits
     top-left, so results continuing directly underneath/beside it keeps
     the eye moving in one direction; the minimap (a supporting reference,
     not the primary thing being scanned) sits off to the side instead of
     forcing a left/right jump. `order` alone does this, no HTML change
     needed -- flex-direction:column in the narrower stacked block below
     respects the same order, so results end up on top there too. */
  .search-results-col{order:1;}
  /* padding-top:0 + top:16px here (Stian, testing) -- the base rule's
     12px padding-top was meant for the stacked layout (minimap sitting
     above the cards, full width, where breathing room made sense once
     pinned); side by side it just pushed the minimap out of alignment
     with the first result card's own top edge, so it's 0 here and the
     gap is done via `top` instead -- lets the pinned box settle 16px
     from the viewport edge rather than flush against it. The
     content-panel's background (.content-panel / .search-minimap-frame
     both var(--panel)) covers the full run of result rows beneath it,
     so that 16px gap only risks showing the page's dark body background
     if you scroll past the very LAST result row entirely -- test that
     specifically with a longer result list before trusting this. Restored
     to top:0/padding-top:12px in the stacked media query below, unchanged
     from before -- this experiment is side-by-side only for now. */
  .search-minimap-frame{order:2;flex:1;min-width:300px;margin-bottom:0;padding-top:0;top:128px;}
  .search-results-col{flex:0 1 585px;max-width:585px;min-width:0;}
}
@media (min-width:991px) and (max-width:1345px){ /* SEARCH_STACK_BREAKPOINT */
  .search-layout{flex-direction:column;}
  /* order reset back to minimap-first here (Stian) -- the base block
     above sets results-first/minimap-second for the side-by-side layout
     (order:1/2), but that's unconditional on any desktop width, so it
     was also pushing the minimap to the very BOTTOM of the stacked
     layout, below every result row, instead of sitting on top the way
     the stacked layout always meant to work. */
  .search-minimap-frame{order:1;flex:none;width:100%;min-width:0;padding-top:12px;top:0;}
  .search-results-col{order:2;max-width:none;flex:1;}
}

/* Mobile shelf-ID -> minimap popup (Stian): below the 990px compact-layout
   breakpoint the inline minimap never even shows (see search.js's
   isCompactSearchLayout), so tapping a result row's shelf-ID badge instead
   pops this up as a modal, reusing the SAME #search-minimap SVG node
   (moved in/out of .mobile-minimap-view by search.js, not rebuilt) so the
   scene/rotation logic doesn't need a second copy. */
.rr-shelf{cursor:default;}
@media (max-width:990px){
  .rr-shelf{cursor:pointer;}
  /* iOS Safari bug (Stian, iPhone 13, inventari.no): .result-row has its
     own :hover rule (border-color) but no cursor:pointer of its own --
     only .rr-shelf (a child) gets one, just above. WebKit's heuristic for
     "does a single tap fire click immediately, or does it first simulate
     :hover and eat the tap" checks the whole ancestor chain for
     cursor:pointer, not just the actual click target -- an ancestor with
     :hover styling but no cursor:pointer anywhere in that chain is exactly
     the shape that makes the FIRST real tap silently do nothing (it just
     "hovers"), which reads as the tap not working at all rather than
     needing a second tap. Giving the row itself cursor:pointer too (this
     changes nothing about which element the click LISTENER lives on --
     still just .rr-shelf, see search.js) satisfies that heuristic. */
  .result-row{cursor:pointer;}
}
/* touch-action:none on the WHOLE overlay, not just the map box below --
   a drag starting on the backdrop or the panel's own padding was still
   free to scroll the page behind it otherwise. */
.mobile-minimap-modal{position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:20px;touch-action:none;}
.mobile-minimap-modal-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.6);}
.mobile-minimap-modal-panel{position:relative;background:var(--panel);border-radius:6px;padding:16px;width:100%;max-width:420px;box-shadow:0 10px 30px rgba(0,0,0,.4);}
.mobile-minimap-view{width:100%;height:220px;background:var(--white);border:1.5px solid var(--line);border-radius:4px;overflow:hidden;margin-bottom:12px;}
.mobile-minimap-view svg{width:100%;height:100%;display:block;}
/* Was a press-and-hold gesture originally, now a plain tap-to-toggle
   (Stian: holding turned out fussy on real phones) -- select-none/
   touch-action:none/-webkit-touch-callout stick around regardless, since
   a tap can still land as an accidental long-press on real hardware, and
   the browser's own text-selection/long-press menu or magnifier fighting
   a tap is exactly as unwanted as it was for a hold. touch-action:none
   (not manipulation) is what actually reaches pinch-zoom/double-tap-zoom
   too, not just the text-selection UI -webkit-touch-callout covers.
   Between this and JS's touchstart preventDefault (search.js), the
   button should behave like a "dead" press target with no OS gesture
   layered underneath it. */
.mobile-minimap-view-btn{width:100%;display:flex;align-items:center;justify-content:center;gap:8px;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;touch-action:none;}
.mobile-minimap-view-btn svg{width:18px;height:18px;}
.mobile-minimap-view-btn.active{background:var(--orange);color:var(--white);border-color:var(--orange);}

/* Fixed column widths (set inline on each <th> in log.html) instead of
   the default auto layout, which re-sized every column to whatever
   content happened to be in the current page/search results -- made
   Shelf ID and everything after it visibly shift left/right on every
   reload even though there was plenty of room to just hold still.
   No more ellipsis anywhere in this cell now -- per Stian, Shelf ID/
   Movement type/Time/User must never be silently truncated with "...",
   and neither must the tag pill or the model_name/leaf-category fallback
   text (a real report: a 10-char tag like "test123456" was getting eaten
   into "test12..." by the old cell-wide nowrap+ellipsis, since it treated
   the whole cell's text -- title AND tag together -- as one clippable
   line). A long title just wraps onto a second line instead now, same as
   everything else in this table; the @container tiers further down still
   drop it outright once things get genuinely cramped. */
.log-table{table-layout:fixed;}
.log-table td{overflow:visible;white-space:normal;}

/* ---------- Movement log: mobile row list + detail modal ---------- */
/* Hidden outside the mobile media query at the bottom of this file,
   which switches this and .log-table (the desktop table) on and off. */
.log-mobile-list{display:none;flex-direction:column;gap:8px;}
.log-row-mobile{
  display:flex;
  align-items:center;
  gap:12px;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:4px;
  padding:12px 14px;
  cursor:pointer;
}
.log-row-mobile:active{border-color:var(--orange);}
.log-row-icon{
  width:24px;height:24px;
  border-radius:50%;
  display:flex;
  align-items:center;
  justify-content:center;
  font-size:13.5px;
  font-weight:700;
  line-height:1;
  color:var(--white);
  flex-shrink:0;
}
.log-icon-plus{background:var(--ok);}
.log-icon-minus{background:var(--err);}
/* Desktop table version -- same badge, scaled down to sit inline with
   the "Placed"/"Removed" text (table font-size is 13px) instead of the
   24px mobile-card size. */
.log-row-icon-inline{
  display:inline-flex;
  width:14px;height:14px;
  font-size:9px;
  vertical-align:middle;
  margin-right:5px;
}
.log-row-main{min-width:0;flex:1;}
/* Wraps instead of single-line ellipsis-truncating now (Stian: "no
   compromise on information being available, avoid (...) wherever we
   can") -- a long product title just takes a second line on the card
   instead of losing its tail end. */
.log-row-title{font-size:14px;font-weight:600;}
.log-row-sub{font-size:12px;color:var(--ink-soft);font-family:'IBM Plex Mono',monospace;margin-top:2px;}
/* Desktop table's Product cell: model name is wrapped in CSS-generated
   parens (not literal "(" ")" text) so the narrowest container-query tier
   below can drop them the moment the brand/title itself gets hidden --
   otherwise a hidden title would leave an orphaned "(model)" floating
   with no parenthetical to attach to. */
.log-row-model::before{content:' (';}
.log-row-model::after{content:')';}
/* Hidden by default -- only shown by the @container tier further down,
   alongside the now-unparenthesized model name, once the title itself
   has been dropped in favor of "model_name - leaf category" (Stian:
   prioritize the tag pill over the title -- this is what stands in for
   the title once it's gone, not a bare model number on its own). */
.log-row-category{display:none;}
/* Movement log tag display -- desktop and the mobile tap-detail modal
   both now use the same .rr-tag badge as everywhere else it shows up
   (search results, EAN print labels) instead of plain colored
   parenthetical text (Stian, wanted it more visible/consistent). The
   compact mobile row itself still only has room for a small flag icon
   (tap the row for the actual badge, see openDetail's Tag row). */
.log-tag-icon{width:12px;height:12px;color:var(--info);vertical-align:-1px;margin-left:2px;}

.log-detail-row{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--line);font-size:13px;}
.log-detail-row:last-child{border-bottom:none;}
.log-detail-label{color:var(--ink-soft);font-family:'IBM Plex Mono',monospace;font-size:11px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;}

/* ---------- List view ---------- */
/* The header "select all" checkbox doubles as "deselect all" once
   anything is checked (see updatePrintBtn in list.js, which drives its
   .checked state off selectedIds.size > 0, not the native per-page
   indeterminate logic) -- appearance:none replaces the default checkbox
   rendering entirely so the checked state can show as a plain red square
   with a white cross instead. background-image (not a ::before/::after
   pseudo-element -- inputs are replaced elements and don't support
   those) is what actually draws the cross. */
#list-select-all{
  appearance:none;
  width:18px;
  height:18px;
  border:1px solid var(--line);
  border-radius:3px;
  background:var(--white);
  cursor:pointer;
  vertical-align:middle;
}
#list-select-all:checked{
  background-color:var(--err);
  border-color:var(--err);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3C/svg%3E");
  background-repeat:no-repeat;
  background-position:center;
  background-size:12px 12px;
}
.print-bar{
  display:flex;
  flex-wrap:wrap;
  justify-content:space-between;
  align-items:center;
  margin-bottom:14px;
  /* Column-gap doubles as the "at least this much room, or wrap" buffer
     Stian asked for -- once the toolbar and button can't both fit with
     50px between them, the button flexes down onto its own row. */
  gap:12px 50px;
}
/* Desktop only, per Stian -- sticks the search/filter toolbar to the top
   of the viewport once Overview's own scroll passes it, so filters stay
   reachable on a long list instead of needing a scroll back to the top.
   background:var(--panel) matches .content-panel's own background
   exactly, so the pinned bar reads as a seamless continuation of the
   panel rather than a mismatched patch -- table rows scrolling underneath
   never show through it. Negative side margins + matching padding extend
   it to the panel's actual edges (this element normally sits inboard of
   .content-panel's own 26px padding) so the solid background has no gap
   at either side once it's the thing painting the backdrop. */
@media (min-width:769px){
  .print-bar{
    position:sticky;
    top:0;
    z-index:3;
    background:var(--panel);
    margin-left:-26px;
    margin-right:-26px;
    padding:14px 26px;
  }
}
.print-bar .toolbar{flex:1 1 320px;}
.print-bar .btn{padding:10px 16px;font-size:12px;flex-shrink:0;}
/* "EAN print" -- plain .btn (orange, same as every other primary button)
   with a printer icon + live selected-count badge, rather than the old
   .btn.ghost outline. Disabled (nothing selected yet) falls back to a
   flat grey and stops accepting clicks -- see the shared .btn:disabled
   rule below. */
.print-bar .btn#list-print-btn{display:inline-flex;align-items:center;gap:8px;}
/* Edit map's "Print shelf labels" (Stian) -- same icon+solid-fill styling
   as Overview's #list-print-btn above now, so the two read as the same
   kind of action across pages. .map-toolbar .btn.small already sets
   display:inline-flex/centering; this only adds the icon/text gap that
   rule didn't need before (every other button in this toolbar is either
   icon-only or text-only). */
#print-shelf-labels-btn{gap:8px;}
#list-print-count{font-family:'IBM Plex Mono',monospace;}
.btn:disabled{background:var(--ink-soft);border-color:var(--ink-soft);color:var(--white);cursor:not-allowed;}

/* Overview's filter row deliberately does NOT get its own taller
   treatment any more -- per Stian, it should match Movement log's
   toolbar height exactly, which just means falling back to the shared
   ".toolbar select, .toolbar input" rule both pages already use, same as
   before that dedicated bump existed. */

/* The 4 filter inputs (Include/Exclude category, Include/Exclude rack
   group) still pick up their base sizing from the shared ".toolbar
   select, .toolbar input" rule above -- this only adds a leading icon +
   matching left border, same "color = include/exclude, icon = category
   vs rack group" language as the chips below use, since Stian found all
   4 too similar to tell apart at a glance even once a chip already
   existed. Icons are the exact same tag/rack-cube shapes as the chips'
   own (see list.js's CHIP_TYPE_ICONS) -- background-image data URIs
   here only because a plain <input> can't hold real inline SVG the way
   a chip <span> can; colors are --ok/--err's own hex (a data URI can't
   reference a CSS variable) so they stay in lockstep if those ever
   change. */
#list-include-input, #list-exclude-input, #list-group-include-input, #list-group-exclude-input{
  background-repeat:no-repeat;
  background-position:9px center;
  background-size:13px 13px;
  padding-left:29px;
}
#list-include-input{
  border-left:3px solid var(--ok);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232f8f4e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.59 13.41 11 3.83 3.83 11l9.58 9.59a2 2 0 0 0 2.83 0l4.35-4.35a2 2 0 0 0 0-2.83Z'/%3E%3Ccircle cx='7.5' cy='7.5' r='1' fill='%232f8f4e' stroke='none'/%3E%3C/svg%3E");
}
#list-exclude-input{
  border-left:3px solid var(--err);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23c93a3a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.59 13.41 11 3.83 3.83 11l9.58 9.59a2 2 0 0 0 2.83 0l4.35-4.35a2 2 0 0 0 0-2.83Z'/%3E%3Ccircle cx='7.5' cy='7.5' r='1' fill='%23c93a3a' stroke='none'/%3E%3C/svg%3E");
}
#list-group-include-input{
  border-left:3px solid var(--ok);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232f8f4e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2 L21 7 L21 17 L12 22 L3 17 L3 7 Z'/%3E%3Cpath d='M12 2 L12 12 M3 7 L12 12 M21 7 L12 12'/%3E%3C/svg%3E");
}
#list-group-exclude-input{
  border-left:3px solid var(--err);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23c93a3a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2 L21 7 L21 17 L12 22 L3 17 L3 7 Z'/%3E%3Cpath d='M12 2 L12 12 M3 7 L12 12 M21 7 L12 12'/%3E%3C/svg%3E");
}
.category-chip-rows{display:flex;flex-direction:column;gap:6px;margin-bottom:14px;}
.category-chip-row{display:flex;flex-wrap:wrap;gap:6px;min-height:0;}
.category-chip{
  display:inline-flex;
  align-items:center;
  gap:6px;
  padding:5px 8px 5px 10px;
  border-radius:14px;
  font-size:12px;
  font-family:'Inter',sans-serif;
  color:var(--white);
}
.category-chip.include{background:var(--ok);}
.category-chip.exclude{background:var(--err);}
/* Color already says include (green) vs exclude (red); this icon is the
   OTHER axis -- category vs rack group -- so the two together tell you
   which of the 4 filter kinds a chip is without reading its text (Stian).
   opacity, not a different color, so it doesn't fight the include/exclude
   color coding for attention. */
.category-chip-icon{width:12px;height:12px;flex-shrink:0;opacity:.85;}
.category-chip-remove{
  flex-shrink:0;
  display:inline-flex;
  align-items:center;
  justify-content:center;
  width:16px;
  height:16px;
  border-radius:50%;
  border:none;
  background:rgba(255,255,255,.25);
  color:var(--white);
  font-size:10px;
  line-height:1;
  cursor:pointer;
  padding:0;
}
.category-chip-remove:hover{background:rgba(255,255,255,.45);}
/* .category-chip-remove's default look (a faint white-on-white circle)
   is meant to sit ON a colored chip pill -- on a plain white table row
   (category-relationships list, custom-products list) that same circle
   is nearly invisible (Stian: "I didn't find it, lol"). Solid red/white
   instead wherever a remove button sits directly on a white row. */
.row-remove-btn{background:var(--err);color:var(--white);}
.row-remove-btn:hover{background:#a92f2f;}

/* ---------- Overview: sortable column headers ---------- */
/* Click to sort by that column, click again to flip direction (see
   list.js) -- the little ▲/▼ next to the label is the only visual cue,
   driven by data-sort-dir (set on the currently-active header only) so
   plain CSS content can show the right glyph without any extra markup. */
.sortable{cursor:pointer;user-select:none;white-space:nowrap;}
.sortable:hover{color:var(--orange-dark);}
.sortable::after{content:'';display:inline-block;width:10px;}
.sortable[data-sort-dir="asc"]::after{content:'▲';font-size:9px;margin-left:3px;}
.sortable[data-sort-dir="desc"]::after{content:'▼';font-size:9px;margin-left:3px;}

/* ---------- Overview: mobile/cramped-desktop card list ---------- */
/* Same idea as Movement log's own .log-row-mobile -- hidden outside the
   @container block further down, which switches this and
   .list-table-wrap (the desktop table) on and off. Every field that
   doesn't fit the compact card is one tap away via openDetail (list.js),
   never hidden/truncated outright -- see that block's own comment. */
.list-mobile-toolbar{display:none;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;}
.list-mobile-select-all{display:flex;align-items:center;gap:6px;font-size:13px;flex-shrink:0;}
/* Same red/white-cross "select all doubles as deselect all" look as the
   desktop header checkbox (#list-select-all), just under its own id since
   it's a genuinely separate checkbox, not the same DOM node reused (the
   table it lives in is display:none on mobile, so that one isn't
   interactable here anyway) -- see list.js for how the two stay in sync. */
#list-select-all-mobile{
  appearance:none;
  width:18px;
  height:18px;
  border:1px solid var(--line);
  border-radius:3px;
  background:var(--white);
  cursor:pointer;
  flex-shrink:0;
}
#list-select-all-mobile:checked{
  background-color:var(--err);
  border-color:var(--err);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3C/svg%3E");
  background-repeat:no-repeat;
  background-position:center;
  background-size:12px 12px;
}
.list-mobile-sort{display:flex;align-items:center;gap:6px;min-width:0;}
.list-mobile-sort select{min-width:0;}
/* Reuses .sortable's own asc/desc arrow rotation instead of swapping SVG
   paths -- one icon, flipped, same as the desktop header's ▲/▼ pair
   conceptually but as a single control since there's no per-column
   header row on mobile to click. */
#list-sort-dir-mobile{padding:8px;flex-shrink:0;}
#list-sort-dir-mobile svg{width:16px;height:16px;transition:transform .15s ease;}
#list-sort-dir-mobile[data-dir="desc"] svg{transform:rotate(180deg);}
.list-mobile-cards{display:none;flex-direction:column;gap:8px;}
.list-mobile-card{
  display:flex;
  align-items:flex-start;
  gap:12px;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:4px;
  padding:12px 14px;
}
.list-mobile-card-check{display:flex;align-items:center;padding-top:2px;}
.list-mobile-card-main{min-width:0;flex:1;cursor:pointer;}
.list-mobile-card-title{font-size:14px;font-weight:600;}
.list-mobile-card-sub{font-size:12px;color:var(--ink-soft);font-family:'IBM Plex Mono',monospace;margin-top:2px;}

/* ---------- Auth gate ---------- */
.auth-gate{
  min-height:100vh;
  display:flex;
  align-items:center;
  justify-content:center;
  background:var(--navy);
}
.auth-card{
  background:var(--panel);
  border-radius:6px;
  padding:32px 30px;
  width:340px;
  display:flex;
  flex-direction:column;
}
.auth-card .logo{margin-bottom:18px;}
/* The very first screen (store email+password) shouldn't carry the
   "POWER" branding at all -- per Stian, that only makes sense once a
   specific store's account is actually known (from the User ID step
   onward, see auth.js's renderGateForEmployee/refresh, which swap the
   label to the real store name instead). Here it's just the mark and
   the product name, bigger and centered rather than a small left-aligned
   lockup. Toggled via auth.js adding/removing this class, not a
   permanently separate block of markup, since the underlying elements
   (mark/divider/label) are the same ones the employee step reuses. */
.logo.logo-neutral{justify-content:center;margin-bottom:26px;}
.logo.logo-neutral .logo-mark{width:52px;height:52px;}
.logo.logo-neutral .logo-divider{display:none;}
.logo.logo-neutral #auth-logo-label{font-size:28px;}
/* Wraps the title + back button so the title can center on the FULL card
   width regardless of whether the back button is showing -- the button
   is absolutely positioned (out of normal flow) rather than a flex
   sibling, since a flex row with one empty/hidden side would otherwise
   throw the title's centering off. */
.auth-title-row{position:relative;margin:0 0 18px;}
.auth-title{
  font-family:'Oswald',sans-serif;
  font-weight:600;
  font-size:20px;
  text-transform:uppercase;
  text-align:center;
  margin:0;
  color:var(--ink);
}
/* Replaces the old "← Back to store login" full-width text button --
   per Stian, one small icon on the title row reads cleaner than a
   written option sitting below the fields. Its click target changes per
   step (see auth.js's setBackButton): store login itself never shows it;
   every step after that goes back exactly one step (PIN -> ID list ->
   store login), not straight back to store login from anywhere. */
.auth-back-btn{
  position:absolute;
  left:0;
  top:50%;
  transform:translateY(-50%);
  background:none;
  border:none;
  cursor:pointer;
  color:var(--ink-soft);
  font-size:22px;
  line-height:1;
  padding:4px 8px;
}
.auth-back-btn:hover{color:var(--ink);}
.auth-card .btn{margin-top:20px;}
/* Employee gate: pick-an-ID-then-PIN (see auth.js's renderGateForEmployeeList/
   Pin) -- a vertical stack of full-width buttons, one per employee, reusing
   .btn.dark (the same neutral grey as the sidebar nav buttons/auth-submit)
   rather than the orange primary color, since there's no single "the"
   button here -- every ID is an equally valid choice. */
.employee-picker{display:flex;flex-direction:column;gap:8px;max-height:50vh;overflow-y:auto;}
.employee-pick-btn{text-align:center;}
/* PIN step: the field and its submit arrow share one row instead of the
   arrow sitting full-width below -- #auth-submit is moved into this row
   by renderGateForEmployeePin (see auth.js) and moved back out again by
   any other step's render. */
.auth-input-row{display:flex;gap:8px;align-items:stretch;}
.auth-input-row .field-input{flex:1;}
.auth-arrow-btn{
  flex-shrink:0;
  width:48px;
  background:var(--navy-2);
  color:var(--white);
  border:none;
  border-radius:3px;
  cursor:pointer;
  font-size:20px;
  display:flex;
  align-items:center;
  justify-content:center;
}
.auth-arrow-btn:hover{background:#31363f;}
.auth-error{
  font-family:'IBM Plex Mono',monospace;
  font-size:12px;
  color:var(--err);
  min-height:16px;
  margin-top:10px;
}

/* ---------- Employee bar ---------- */
.employee-bar{
  background:var(--navy-2);
  border-radius:3px;
  padding:11px 12px;
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:8px;
  margin-bottom:6px;
}
.employee-bar .s-sub{color:#9aa0a8;font-size:10px;text-transform:uppercase;letter-spacing:.05em;}
/* Mobile-only (see @media (max-width:768px) below) -- desktop already
   shows the store name via the sidebar logo, so this would just be a
   duplicate there. */
.employee-store{display:none;color:#9aa0a8;font-size:10px;text-transform:uppercase;letter-spacing:.05em;}
.employee-name{
  color:var(--white);
  font-family:'IBM Plex Mono',monospace;
  font-size:13px;
  font-weight:600;
}
.btn.small{padding:7px 10px;font-size:11px;}
/* "Edit map" otherwise auto-sizes to its own text length -- a fixed 70px
   was too tight for it at this uppercase/letter-spaced size (would clip
   or wrap). */
#settings-edit-map-btn{min-width:90px;}
/* Scoped to the (dark) employee bar only -- .btn.ghost elsewhere keeps its
   normal dark-on-light look from the base rule above. */
.employee-bar .btn.ghost{color:var(--white);border-color:#4a4f57;}
.btn.danger{
  background:var(--err);
  border:1px solid var(--err);
  color:var(--white);
}
.btn.danger:hover{background:#a92f2f;border-color:#a92f2f;}

/* ---------- Modal ---------- */
.modal-backdrop{
  position:fixed;
  inset:0;
  background:rgba(0,0,0,.5);
  display:flex;
  align-items:center;
  justify-content:center;
  z-index:50;
}
/* Locks the PAGE behind the modal from scrolling at all while any
   modal-backdrop is open (:has() -- widely supported since 2022, fine
   for this app's real device set). Giving the modal-card its own
   max-height/overflow-y (below) wasn't actually enough on its own, per
   Stian's phone testing -- the page behind was still scrollable, so a
   touch that started even slightly outside the card's own bounds (easy
   to do one-handed) scrolled the BODY instead of the card, which reads
   exactly like "the modal itself won't scroll" even though technically
   it would have, for a touch that started squarely inside it. */
body:has(.modal-backdrop:not([hidden])){overflow:hidden;}
/* max-height + overflow-y:auto so a modal-card taller than the viewport
   (a long form on a short phone screen) scrolls WITHIN itself instead of
   overflowing past the screen edges. -webkit-overflow-scrolling:touch is
   a known necessity, not just a nicety -- iOS Safari has a long-standing
   bug where a touch-drag inside a scrollable element that sits under a
   position:fixed ancestor (our backdrop) doesn't scroll it at all
   without this. overscroll-behavior:contain stops scrolling the card
   all the way to its end from then "chaining" into the page behind. */
.modal-card{
  background:var(--panel);
  border-radius:6px;
  padding:26px;
  width:300px;
  max-height:calc(100vh - 48px);
  overflow-y:auto;
  -webkit-overflow-scrolling:touch;
  overscroll-behavior:contain;
  display:flex;
  flex-direction:column;
}
.modal-title{
  font-family:'Oswald',sans-serif;
  font-weight:600;
  font-size:18px;
  text-transform:uppercase;
  margin:0 0 16px;
  color:var(--ink);
}
.modal-actions{display:flex;gap:10px;margin-top:18px;justify-content:flex-end;}
/* max-width:92vw (not just a mobile media-query override, always on) --
   an inline style="width:Npx" would otherwise beat any later media-query
   rule regardless of viewport (inline style always wins on specificity),
   which is exactly what silently broke these two on mobile before they
   had their own classes. */
.category-pref-modal-card{width:600px;max-width:92vw;}
.custom-product-modal-card{width:640px;max-width:92vw;}

/* Glue-confirm prompt (Stian) -- centered/iconed instead of the plain
   left-aligned title + gray sentence every other modal uses, since this
   one's a single yes/no moment about two specific racks rather than a
   form or a list, and reads better as one small focused visual instead
   of a sentence to parse. Chips carry the actual rack codes prominently
   (buried in a sentence before); the icon reuses the same two-hooks
   "link" shape as .proximity-link-magnet's drag-time cue, so the visual
   language stays consistent between "about to glue" and "confirm glue". */
.glue-confirm-card{align-items:center;text-align:center;}
.glue-confirm-card .modal-title{margin-bottom:2px;}
.glue-confirm-icon{width:34px;height:34px;color:var(--orange);margin-bottom:10px;}
.glue-confirm-chips{display:flex;align-items:center;justify-content:center;gap:10px;margin:14px 0 10px;}
.glue-confirm-chip{
  font-family:'IBM Plex Mono',monospace;
  font-weight:700;
  font-size:15px;
  color:var(--ink);
  background:var(--panel-2);
  border:1px solid var(--line);
  border-radius:4px;
  padding:6px 12px;
}
.glue-confirm-chip-link{color:var(--ink-soft);font-size:18px;font-weight:600;}
.glue-confirm-sub{margin:0;}
.glue-confirm-card .modal-actions{justify-content:center;width:100%;}

/* ---------- Available spaces (desktop slide-in drawer) ----------
   Place product's "show available spaces" button (Stian) -- desktop
   version, a drawer sliding in from the right rather than the mobile
   modal (#avail-modal, styled for free by the existing .modal-backdrop/
   .modal-card rules above). Deliberately its own classes, not a reuse of
   Search's .search-layout/.search-minimap-frame -- those carry
   position:sticky and breakpoint-specific overrides tuned for a normal
   page's scroll behavior, neither of which apply inside a fixed-size
   overlay drawer. .search-minimap itself (the actual bordered/shadowed
   box, not its sticky frame) IS reused -- that part's just a visual
   style with no page-coupling. */
.avail-panel-backdrop{
  position:fixed;
  inset:0;
  background:rgba(0,0,0,.5);
  z-index:50;
  display:flex;
  justify-content:flex-end;
}
.avail-panel{
  background:var(--panel);
  width:820px;
  max-width:92vw;
  height:100%;
  display:flex;
  flex-direction:column;
  padding:22px 26px;
  box-shadow:-4px 0 24px rgba(0,0,0,.3);
  animation:avail-panel-slide-in .2s ease;
}
@keyframes avail-panel-slide-in{
  from{transform:translateX(40px);opacity:0;}
  to{transform:translateX(0);opacity:1;}
}
.avail-panel-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;}
.avail-panel-head h2{
  font-family:'Oswald',sans-serif;
  font-weight:600;
  font-size:18px;
  text-transform:uppercase;
  margin:0;
  color:var(--ink);
}
.avail-panel-layout{display:flex;gap:16px;flex:1;min-height:0;}
.avail-panel-minimap-frame{flex:0 0 340px;position:relative;display:flex;flex-direction:column;gap:8px;}
.avail-panel-results{flex:1;min-width:0;overflow-y:auto;display:flex;flex-direction:column;gap:8px;}
.avail-candidate-row{
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  padding:10px 14px;
  cursor:pointer;
  transition:border-color .15s;
}
.avail-candidate-row:hover, .avail-candidate-row.is-hovered{border-color:var(--orange);}
.avail-candidate-shelf{font-family:'IBM Plex Mono',monospace;font-weight:600;font-size:14px;}
.avail-candidate-meta{font-size:12px;color:var(--ink-soft);margin-top:2px;}
/* Explains the sort itself (Stian) -- two small pills per candidate, same
   outline-pill look as .rr-tag: how tight this shelf's free space is
   against the item's own footprint, and how well this shelf's recent
   placement history matches the item's category. Colors are shared
   across both badge kinds by MEANING (green=best case, blue=partial/
   decent, gray=neutral/no signal) rather than one palette per badge, so
   the color itself is legible without reading the label text. */
/* Only present when the server's best spot for this candidate is the flat
   top of an already-placed item rather than real shelf floor (stackOn,
   see packSurfaces in placements.js) -- an --edit (purple) accent since
   it's neither a "good/bad" judgement like the badges below nor plain
   metadata like the room/rack line above it, just a distinct instruction. */
.avail-candidate-stack-on{font-size:12px;color:var(--edit);font-weight:600;margin-top:4px;}
.avail-candidate-badges{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;}
.avail-badge{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.02em;padding:3px 7px;border-radius:9px;border:1px solid;}
.avail-badge-snug, .avail-badge-strong{color:var(--ok);border-color:var(--ok);}
.avail-badge-good, .avail-badge-related{color:var(--info);border-color:var(--info);}
.avail-badge-roomy, .avail-badge-none{color:var(--ink-soft);border-color:var(--line);}
.avail-panel-empty{color:var(--ink-soft);font-size:13px;padding:8px 2px;}
/* Mobile modal version of the same candidate list -- same card look as
   the desktop drawer (.avail-candidate-row, shared markup from
   place.js), just no minimap beside it and a wider/scrollable card
   instead of the plain 300px modal-card. */
/* overflow:hidden here is what actually makes max-height stick -- without
   it, the card (a flex column) just grew taller than 80vh to fit its
   content instead of clipping, which is what let the body's own
   overflow-y:auto below silently do nothing (there was nothing forcing
   it to actually run out of room). */
.avail-modal-card{width:92vw;max-width:420px;max-height:80vh;overflow:hidden;}
/* min-height:0 is the actual fix for "scrolling didn't work" (Stian) --
   a flex child defaults to min-height:auto, which refuses to shrink
   below its own content's intrinsic height no matter what max-height/
   overflow-y say, so this element just kept growing past 60vh instead
   of clipping and scrolling. Classic flexbox-overflow gotcha. */
.avail-modal-body{display:flex;flex-direction:column;gap:8px;overflow-y:auto;max-height:60vh;min-height:0;}

/* ---------- Print ---------- */
.print-only{display:none;}
@media print{
  /* "body *" below only covers body's DESCENDANTS -- body itself (dark
     navy in the normal app chrome) was never included, so its own
     background kept painting behind everything, wasting ink on every
     printed label sheet. */
  html, body{background:#fff;}
  /* The desktop-only 1.4 zoom (see the @media (min-width:769px) block
     further down) lives on main/.sidebar-inner now, not body -- reset
     both back to 1 here so physical print sizes (shelf labels etc.) stay
     exact regardless of on-screen zoom. */
  main, .sidebar-inner{zoom:1;}
  body *{visibility:hidden;background-color: white;}
  .print-only, .print-only *{visibility:visible;}
  .print-only{display:block;position:absolute;top:0;left:0;width:100%;background-color: white;}
  /* Back to portrait (A4) -- per Stian, worth trying again now that
     labels print sorted by rack block instead of scattered, revisiting
     the earlier landscape default. */
  @page{size:A4 portrait;}
  .print-labels-grid{display:flex;flex-wrap:wrap;gap:8px;}
  .print-label{border:1px dashed #999;padding:10px;break-inside:avoid;box-sizing:border-box;width:calc(33.333% - 6px);position:relative;}
  /* Upper-right corner of the label, same idea as search's .rr-tag-corner
     (Stian) -- reuses .rr-tag's own badge styling, just repositioned and
     absolute so it doesn't compete with the title/barcode for space on an
     already-tight printed label. Only ever added when r.tag exists (see
     list.js), so no empty corner badge on untagged labels. */
  .print-label-tag{position:absolute;top:6px;right:6px;}
  /* Shelf labels flow as COLUMNS, not rows -- per Stian, a rack's whole
     block of labels should read top-to-bottom as one vertical strip you
     can cut out in one piece. Flexbox's flex-direction:column doesn't
     paginate across physical print pages properly (browsers don't know
     how to split a column-direction flex container at a page boundary,
     which is what caused the overlapping mess) -- CSS multi-column layout
     is the print-safe way to get the same effect, since browsers DO know
     how to flow multi-column content across pages. Each rack's own
     labels stack as plain block content (no flex needed there, block
     layout paginates fine on its own) inside .shelf-rack-group, which
     break-inside:avoid keeps from splitting mid-rack across a column/page
     when the whole rack fits; a rack with more stories than fit on one
     page/column still spills over as-is, per Stian. */
  /* column-count (not the "columns: N px" shorthand) -- the shorthand
     lets the browser pick MORE columns than requested once each is wider
     than its minimum, which is what silently produced 3 instead of 2.
     column-count pins it to exactly 2, always. */
  .shelf-labels-grid.print-labels-grid{display:block;column-count:2;column-gap:8px;}
  .shelf-rack-group{break-inside:avoid;margin-bottom:8px;}
  .shelf-labels-grid .print-label{width:100%;margin-bottom:8px;}
  .print-label-title{font-weight:600;font-size:32px;}
  /* "Print selected EANs" (list.js) shares .print-label markup with the
     shelf-ID labels (map-edit.js) but needs its own, much smaller title
     size -- these labels also carry a category/shelf subtitle line the
     shelf-ID ones don't, so 32px read as oversized here specifically. */
  .print-label-title.small{font-size:16px;}
  /* Shelf-ID labels only (not the EAN/product ones above) -- big yellow
     text, black border box, so the ID reads clearly at a glance from a
     distance on the physical shelf. Kept at 128px per Stian (not shrunk
     back down) -- the actual overlap bug was white-space:nowrap forcing
     a long code (e.g. "A10-2") to ignore its box's width entirely and
     spill sideways into the next column, rather than the font size
     itself. Removing nowrap and constraining to max-width:100% (the
     inline-block's actual "hitbox", tied to .print-label's own width)
     means a code that doesn't fit on one line at this size just wraps
     onto a second line WITHIN its own box instead -- taller labels, not
     overlapping ones. */
  .print-label-title.shelf-id-label{
    background-color:#cfa90f;
    font-size:128px;
    color:solid #000;
    border:2px solid #000;
    display:inline-block;
    max-width:100%;
    box-sizing:border-box;
    padding:2px 10px;
    border-radius:4px;
    overflow-wrap:break-word;
  }
  /* color was #ffff (4-digit hex = white, fully opaque) -- invisible
     white-on-white against the label's plain white background, so the
     leaf-category/shelf-ID subtitle (see list.js) never actually showed
     up on a printed EAN label despite being right there in the HTML.
     No earlier git history to say when that started (this repo's history
     begins at a single squashed baseline commit) -- it may never have
     been visible. #555 -- a normal muted-secondary gray, readable on
     plain paper. */
  .print-label-sub{font-family:monospace;color:#555;margin-bottom:6px;}
  .print-label-barcode{display:block;margin-top:4px;}
}

/* ---------- Settings ---------- */
/* Same "shrink to fit, center in the content area" treatment as
   .place-panel -- the content itself (.settings-grid) already caps at
   520px, but the panel around it used to stretch to fill the whole main
   area regardless, leaving a big empty gap on the right on desktop. */
.settings-panel{
  flex:none;
  min-height:0;
  align-self:center;
  width:640px;
  max-width:100%;
}
/* .settings-panel (640px) is deliberately a bit wider than this content
   (520px) -- rather than shrinking the panel to match exactly, the extra
   width is left as breathing room and this (and the "Employees" heading
   above the second one, see settings.html) are centered within it via
   margin:auto instead of sitting flush left. */
.settings-grid{display:flex;flex-direction:column;gap:22px;max-width:520px;margin-left:auto;margin-right:auto;}
/* Collapsible settings sections (Stian: the page was getting long once
   Category relationships landed) -- header is a plain button styled like
   the app's other buttons (Oswald, uppercase) rather than a heading, so
   it visibly reads as something you click. Chevron spins via the
   .open class settings.js toggles alongside the body's hidden attribute
   -- CSS transition on transform, not a second JS-driven animation. */
.settings-section{max-width:640px;margin:0 auto 14px;}
/* Same navy/white treatment as the sidebar's .nav-btn (Stian: these
   should read as obviously clickable the same way the nav is) -- once
   open, the header itself stays navy (it's still a clickable toggle,
   not the content), only .settings-section-body's own white cards
   underneath switch to the normal light/dark card look. */
.settings-section-header{
  display:flex;
  justify-content:space-between;
  align-items:center;
  width:100%;
  background:var(--navy-2);
  border:none;
  border-radius:3px;
  padding:14px 16px;
  font-family:'Oswald',sans-serif;
  font-weight:600;
  font-size:15px;
  text-transform:uppercase;
  letter-spacing:.03em;
  color:var(--white);
  cursor:pointer;
  transition:background .15s;
}
.settings-section-header:hover{background:#31363f;}
.settings-section-header.open{background:var(--navy);}
.settings-section-chevron{width:18px;height:18px;flex-shrink:0;color:var(--white);transition:transform .2s ease;}
.settings-section-header.open .settings-section-chevron{transform:rotate(180deg);}
/* Slide animation: max-height is transitioned (not display/hidden, which
   can't animate), measured and set inline by settings.js from the real
   content height on each open/close. The 14px gap between the header and
   the body's actual content is a ::before spacer rather than margin/
   padding directly on this element -- padding on the max-height element
   itself doesn't get clipped to 0 by overflow:hidden (a CSS quirk), but
   a spacer as content does. */
.settings-section-body{max-height:0;overflow:hidden;transition:max-height .3s ease;}
.settings-section-body::before{content:"";display:block;height:14px;}
.settings-row{
  display:flex;
  justify-content:space-between;
  align-items:center;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  padding:16px;
}
.settings-row .s-label{font-weight:600;font-size:14px;}
.settings-row .s-sub{font-size:12px;color:var(--ink-soft);margin-top:2px;}
/* Language switch placeholder (just the flag + a one-item dropdown for
   now, see settings.html) -- .s-row-left wraps the flag button alongside
   whatever label/sub normally sits alone on a .settings-row's left side,
   without disturbing that row's own label-left/value-right space-between
   layout (this wrapper is just ONE of its two flex children). */
.s-row-left{display:flex;align-items:center;gap:10px;position:relative;}
.lang-flag-btn{
  display:flex;align-items:center;justify-content:center;
  background:none;border:1px solid var(--line);border-radius:3px;
  padding:6px 8px;cursor:pointer;
}
.lang-flag-btn:hover{background:var(--panel);}
.lang-dropdown{
  position:absolute;top:calc(100% + 6px);left:0;z-index:20;
  background:var(--white);border:1px solid var(--line);border-radius:3px;
  box-shadow:0 2px 8px rgba(0,0,0,.15);min-width:150px;padding:4px;
}
.lang-dropdown-item{
  display:flex;align-items:center;gap:8px;
  padding:8px 10px;border-radius:3px;font-size:13px;cursor:pointer;
}
.lang-dropdown-item:hover{background:var(--panel);}
.lang-dropdown-item.is-selected{font-weight:600;}
/* Grows a conditionally-shown row open instead of it just instantly
   appearing (Stian, re: the admin-only Storage map row) -- same 0fr->1fr
   grid-template-rows technique as .story-collapse (map-edit.js). Lives
   here rather than on .settings-row itself since .settings-row needs to
   stay a plain flex row (label/value side by side) the whole time --
   this wraps it instead of replacing its layout. margin (not just
   height) collapses to 0 too: .settings-grid's own `gap` between flex
   children still reserves its full 22px above AND below this element
   regardless of how tall it currently is (flex gap isn't part of an
   item's own box), so without cancelling it there'd be a lingering
   44px gap even while collapsed. */
.row-reveal{
  display:grid;
  grid-template-rows:0fr;
  opacity:0;
  margin:-11px 0;
  transition:grid-template-rows .25s ease, opacity .2s ease, margin .25s ease;
}
.row-reveal.open{grid-template-rows:1fr;opacity:1;margin:0;}
.row-reveal-inner{overflow:hidden;min-height:0;}
/* Available-spaces priority card -- one single box (label, description,
   icons, slider) rather than two separate cards with a gap between them
   (Stian: the split used to read as two disconnected pieces). Overrides
   the base .settings-row's row layout (meant for a simple label-left/
   value-right pair) with a stacked column instead, since this card has
   several pieces on top of each other. */
.settings-priority-card{flex-direction:column;align-items:stretch;gap:2px;}
.settings-priority-card #settings-priority-edit{margin-top:10px;}
/* Sweep plan auto-cancel input (Stian: center it, drop the spinner
   arrows since typing is easier than clicking them, and label the unit
   right next to it so "4" alone never reads as ambiguous). */
.settings-sweep-expiry-edit{margin-top:10px;display:flex;justify-content:center;align-items:center;gap:8px;}
.settings-sweep-expiry-input{max-width:70px;text-align:center;}
.settings-sweep-expiry-input::-webkit-inner-spin-button,
.settings-sweep-expiry-input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0;}
.settings-sweep-expiry-input{-moz-appearance:textfield;}
.settings-priority-icons{display:flex;justify-content:space-between;margin-bottom:6px;}
.settings-priority-icon{width:20px;height:20px;color:var(--ink-soft);}
/* Category relationship builder (Stian) -- reuses .settings-priority-card
   for the same stacked card look, and .category-chip-row/.category-chip
   from Overview's own include/exclude filters so a staged category reads
   exactly the same way here as it does there. Horizontal line layout --
   [Category A] -- [condition] -- [Category B] -- rather than 3 separate
   stacked rows, which read as cramped/disconnected on the narrower
   520px card this used to share with the priority slider (this card now
   gets its own wider 760px grid, see settings.html). align-items:
   flex-start rather than center -- the two side columns grow taller as
   chips wrap onto more lines, and the condition column shouldn't drift
   down to chase whichever side happens to be tallest at the moment. */
.category-pref-builder{display:flex;align-items:flex-start;gap:16px;}
.category-pref-side{flex:1;min-width:0;}
.category-pref-condition{flex:0 0 auto;width:180px;display:flex;flex-direction:column;align-items:center;gap:8px;padding-top:2px;}
/* A click flips data-mode between exclude/force (see settings.js), which
   swaps the icon (red cross vs. green check) and the sub-text below --
   a toggle button reads faster at a glance than the plain-word dropdown
   this replaced, and the sub-text spells out the pair's actual meaning
   for anyone unsure what the icon alone implies. */
.category-pref-toggle{
  width:44px;
  height:44px;
  border-radius:50%;
  border:1px solid var(--line);
  background:var(--white);
  display:flex;
  align-items:center;
  justify-content:center;
  cursor:pointer;
}
.category-pref-toggle:hover{border-color:var(--orange);}
.category-pref-mode-icon{width:22px;height:22px;}
#category-pref-mode-desc{font-size:11px;line-height:1.3;}
.category-pref-rule-list{margin-top:14px;display:flex;flex-direction:column;gap:6px;}
/* Mirrors the modal's own A / icon / B builder shape instead of reading
   as a flat sentence (Stian) -- Category A and B each take up the full
   available width on their side (flex:1), meeting at a centered icon
   column with a thin border on each side as the dividing line. */
.category-pref-rule{
  display:flex;
  align-items:center;
  gap:10px;
  padding:8px 10px;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  font-size:13px;
}
.category-pref-rule-side{flex:1;min-width:0;}
.category-pref-rule-a{text-align:right;}
.category-pref-rule-b{text-align:left;}
.category-pref-rule-icon{
  flex:0 0 auto;
  display:flex;
  align-items:center;
  padding:0 14px;
  border-left:1px solid var(--line);
  border-right:1px solid var(--line);
}
.category-pref-rule-icon .category-pref-mode-icon{width:16px;height:16px;}

/* Add custom product (Stian, Settings) -- flex-wrap row of fields on
   desktop, each shrinking to its own full line once the modal is narrow
   enough (phone width) that even two ~160px fields can't sit side by
   side -- no separate mobile markup or media query needed, min-width
   alone does it. -wide fields (leaf category/model/title -- the ones
   that tend to hold a full sentence) always take a full row regardless
   of available space. */
.custom-product-form{display:flex;flex-wrap:wrap;gap:12px;margin-top:4px;}
.custom-product-field{flex:1 1 160px;min-width:140px;}
.custom-product-field-wide{flex-basis:100%;}
/* Flagged on submit for whichever required fields were left empty
   (Stian: wanted a way to spot which one was missed at a glance) --
   settings.js clears it again the moment that one field gets typed in,
   plus a timed fade so it doesn't linger forever if the field's just
   left alone. */
.field-input-error{border-color:var(--err) !important;box-shadow:0 0 0 1px var(--err);transition:border-color .6s ease,box-shadow .6s ease;}
.custom-product-list{margin-top:14px;overflow-x:auto;}
.custom-product-list table{width:100%;border-collapse:collapse;font-size:13px;}
.custom-product-list th{text-align:left;padding:6px 8px;font-size:11px;text-transform:uppercase;letter-spacing:.03em;color:var(--ink-soft);border-bottom:1px solid var(--line);white-space:nowrap;}
.custom-product-list td{padding:6px 8px;border-bottom:1px solid var(--line);white-space:nowrap;}
.custom-product-list tr:last-child td{border-bottom:none;}
/* A superseded custom entry (a real catalog import overwrote it, see
   importCsv.js) is kept as history rather than deleted, but dimmed and
   labeled so it doesn't read as an active, still-in-effect entry. */
.custom-product-row-disabled{opacity:.55;}
.custom-product-superseded-badge{font-size:10px;text-transform:uppercase;letter-spacing:.03em;color:var(--ink-soft);border:1px solid var(--line);border-radius:3px;padding:2px 6px;white-space:nowrap;}
/* touch-action:none -- without it, a touch drag on the slider has no
   declared purpose as far as the browser's gesture handling is
   concerned, so on release it can get reinterpreted as a page pull-to-
   refresh instead of purely a slider drag (same class of issue the map
   canvas already works around with its own touch-action rule). */
#settings-priority-slider{touch-action:none;}

/* ---------- Desktop 140% content scale ---------- */
/* Per Stian's own Chrome-zoom test looking better than the default 100%
   -- explicitly scoped to desktop only (unlike the first attempt, which
   zoomed `body` and affected mobile too), and deliberately NOT applied to
   .sidebar itself (see .sidebar-inner's own comment above for why that's
   unsafe with height:100vh). Uses the CSS `zoom` property rather than a
   transform because it actually rescales layout -- padding, font-size,
   gaps -- matching what real browser zoom looks like, and because nested
   zoom values MULTIPLY with their ancestor's, which is what lets
   .map-frame/.map-canvas and .settings-panel below dial themselves back
   down locally instead of scaling by the full 1.4 like everything else
   in main. */
@media (min-width:769px){
  .sidebar-inner{zoom:1.4;}
  main{zoom:1.4;}
  /* 1.4 * (1/1.4) = 1 exactly -- the map stays its current physical size
     while the page around it (toolbar, hint text, buttons) is 1.4x
     bigger like everywhere else. Covers both the view-only map and Edit
     map (they share these two classes). */
  .map-frame, .map-canvas{zoom:0.7142857;}
  /* Per Stian: Settings' content read better about 15% smaller than the
     rest of the app -- 1.4 * 0.85 ≈ 1.19x overall here, a more modest
     bump than the full 1.4x everywhere else in main. */
  .settings-panel{zoom:0.85;}
  /* Login/auth-gate card, +33% on desktop -- .auth-card has no
     height:100vh (only .auth-gate does, and that's untouched), so unlike
     .sidebar there's no vh-vs-zoom pitfall here: the whole card and its
     contents (padding, font-size, inputs, buttons) can just zoom as one
     unit. Mobile keeps its own separate width rule further down and never
     sees this block. */
  .auth-card{zoom:1.33;}
  /* Same vh-vs-zoom pitfall as .sidebar above (see its comment): a modal
     lives inside zoomed `main`, but .modal-card's max-height is a `vh`
     calc, which always measures the TRUE viewport regardless of any
     ancestor's zoom -- so the box that calc allows through then renders
     1.4x TALLER again on screen, pushing its own bottom (Save/Cancel,
     here the last story row) below the real viewport edge even though
     overflow-y:auto never actually got a chance to engage (the box
     LOOKED like it fit, per its own pre-zoom math). Dividing the
     allowance by 1.4 up front cancels that out, same trick as
     .map-frame/.map-canvas's 0.7142857 above -- the modal still renders
     at the same 1.4x visual size as the rest of desktop, it just now
     actually stops at the real screen edge instead of past it. */
  .modal-card{max-height:calc((100vh - 48px) / 1.4);}
}

/* ==================================================================
   Mobile (mainly Zebra Android scanners) -- everything above this
   point is the untouched desktop layout. Every rule below only takes
   effect at <=768px, the same breakpoint isMobileViewport() (script.js)
   checks in JS, so CSS and JS agree on what counts as "mobile."
   ================================================================== */
@media (max-width: 768px) {
  /* ---------- Inputs: iOS Safari auto-zoom fix ----------
     iOS Safari auto-zooms the WHOLE PAGE when a focused input/select/
     textarea's computed font-size is under 16px -- and, unlike zooming
     in, never auto-resets back out on blur, so it just looks like the
     page is permanently stuck zoomed in after typing anywhere on a
     phone (Stian's report). Blanket element-selector floor (not
     !important) rather than hunting down every input: a bare `input`
     selector has LOWER specificity than any class, so this only affects
     inputs/selects with no more specific rule of their own (several
     toolbar search/filter fields -- #log-search, #list-search, and
     friends -- had no explicit font-size at all, relying on the
     browser's ~13px default) and can't accidentally shrink something
     already sized above 16px, like .room-name-input's 20px below,
     which stays untouched precisely because its class beats this. */
  input, select, textarea{font-size:16px;}
  /* .field-input (14px) and .place-tag-other (12px) DO have their own
     class rule, so the blanket floor above can't reach them -- same
     fix, explicit override instead. */
  .field-input, .place-tag-other{font-size:16px;}

  /* ---------- App shell: sidebar becomes a slide-out drawer ----------
     Replaces the old always-visible top button grid (per Stian, it ate
     too much of the screen on every single page) with a slim sticky
     topbar (.mobile-topbar, index.html) plus the full sidebar tucked
     off-canvas to the left, opened via the hamburger button (script.js
     toggles the "open" class on both .sidebar and .sidebar-backdrop).
     .sidebar keeps its normal desktop-shaped vertical list here --
     position:fixed is the real change from the base rules; logo, the
     nav-btn stack, and .employee-bar all just slide in as-is, unzoomed,
     with .employee-bar staying in its natural bottom spot (nav-spacer's
     flex:1 above it does the same push-to-bottom job it does on desktop
     -- no separate mobile placement needed now that this isn't a
     horizontal row sharing space with the logo any more). */
  /* min-height:100vh + an explicit 1fr row is what makes #main-content
     (below) actually fill the screen below .mobile-topbar/.mobile-page-
     dots even when a page's own content is shorter than that -- without
     it, .app (and so main, a grid item that stretches to fill its own row
     by default) only grows as tall as its content needs, leaving genuine
     empty body background beneath a short page that a swipe (script.js)
     starting there can't reach, since it's not actually part of
     #main-content's own box at all. Three explicit rows (not two) now --
     topbar, the page-dots strip right under it, then main. */
  .app{grid-template-columns:1fr;min-height:100vh;grid-template-rows:auto auto 1fr;}
  .mobile-topbar{
    display:grid;
    grid-template-columns:38px 1fr 38px;
    align-items:center;
    gap:10px;
    background:var(--orange);
    padding:10px 14px;
    position:sticky;
    top:0;
    z-index:20;
  }
  .mobile-menu-btn{
    grid-column:1;
    width:38px;
    height:38px;
    background:var(--navy-2);
    color:var(--white);
    border:none;
    border-radius:3px;
    padding:0;
    display:flex;
    align-items:center;
    justify-content:center;
    cursor:pointer;
  }
  .mobile-menu-btn svg{width:20px;height:20px;}
  /* grid-column:2 (not just flex:1/text-align:center) is what actually
     centers this on the FULL bar width -- the empty 3rd grid track
     (same 38px as the button) balances the hamburger's width on the
     left, instead of the text centering only within the leftover space
     after it and reading as off-center to the right. */
  .mobile-topbar-title-wrap{
    grid-column:2;
    position:relative;
    height:100%;
    overflow:hidden;
  }
  /* Both the real title and its shadow (index.html -- the incoming
     page's title, crossfaded in during a swipe, script.js) stack exactly
     on top of each other via position:absolute so they can overlap
     mid-crossfade instead of pushing each other around. transition
     covers the SPRING-BACK case (an aborted swipe, under the commit
     threshold) -- .page-dragging below turns it off while the drag
     itself is live-tracking the finger every pointermove frame, same
     "disable during, re-enable to let it snap back" trick .sidebar uses
     for its own drag. */
  .mobile-topbar-title{
    grid-column:unset;
    position:absolute;
    inset:0;
    display:flex;
    align-items:center;
    justify-content:center;
    font-family:'Oswald',sans-serif;
    font-weight:600;
    font-size:18px;
    text-transform:uppercase;
    color:var(--white);
    overflow:hidden;
    text-overflow:ellipsis;
    white-space:nowrap;
    transition:transform .22s ease, opacity .22s ease;
  }
  #mobile-topbar-title-shadow{opacity:0;}
  .mobile-topbar-title-wrap.page-dragging .mobile-topbar-title{transition:none;}

  /* ---------- Mobile page dots (script.js) ----------
     One dot per swipeable page, same order as the swipe gesture itself --
     a thin strip of its own row right under the topbar, not squeezed into
     it, so it doesn't fight the title for space. Built/kept in sync by
     script.js (activatePage), since the page order/count can change
     (nav reordering, a page hidden on mobile) without this CSS caring. */
  .mobile-page-dots{
    display:flex;
    justify-content:center;
    align-items:center;
    gap:6px;
    padding:6px 0;
    background:var(--orange);
  }
  .mobile-page-dot{
    width:6px;
    height:6px;
    border-radius:50%;
    background:rgba(0,0,0,.25);
    transition:background .2s ease, width .2s ease;
  }
  /* Widened into a little pill rather than just a color swap -- reads
     clearly as "current" at a glance rather than needing to distinguish
     two similarly-sized dots by shade alone. */
  .mobile-page-dot.active{width:16px;border-radius:3px;background:var(--navy-2);}

  /* position:fixed takes .sidebar out of .app's grid flow entirely (an
     out-of-flow grid item consumes no row/column -- the same trick its
     sticky->static switch above already relied on), so #main-content
     alone fills the single 1fr track left behind, right under
     .mobile-topbar. Slides via transform (not left/right), so nothing
     about that layout shifts while it animates. */
  .sidebar{
    position:fixed;
    top:0;
    left:0;
    height:100vh;
    width:82vw;
    max-width:300px;
    padding:20px 16px;
    overflow-y:auto;
    transform:translateX(-100%);
    transition:transform .25s ease;
    z-index:55;
    box-shadow:2px 0 16px rgba(0,0,0,.35);
    /* Claims horizontal drags for the swipe-to-close gesture (script.js)
       while still letting a touch scroll the button list vertically on a
       short screen -- same pattern as .map-canvas's own pan-y rule. */
    touch-action:pan-y;
  }
  .sidebar.open{transform:translateX(0);}
  /* Drag in progress (script.js) -- transform is driven by inline style
     instead (live-tracking the finger), so the normal CSS transition
     would fight it every single pointermove frame. */
  .sidebar.dragging{transition:none;}
  .sidebar-backdrop{
    display:block;
    position:fixed;
    inset:0;
    background:rgba(0,0,0,.5);
    z-index:54;
    opacity:0;
    pointer-events:none;
    transition:opacity .25s ease;
    /* No content of its own to scroll (unlike .sidebar's pan-y) -- claims
       the drag outright so the browser doesn't intercept it as its own
       edge-swipe/pull-to-refresh gesture (script.js). */
    touch-action:none;
  }
  .sidebar-backdrop.open{opacity:1;pointer-events:auto;}
  /* Same reasoning/pattern as .modal-backdrop's own body-lock further
     down -- without it, a touch that lands on the backdrop can still
     scroll the page behind the open drawer on iOS Safari. */
  body:has(.sidebar.open){overflow:hidden;}

  /* No branding in the drawer (Stian) -- .employee-store below already
     carries the store name, so the logo was pure dead space here. */
  .sidebar .logo{display:none;}
  .employee-store{display:block;}

  /* Drawer order, top to bottom: employee bar (Log out) pinned to the
     top, then the nav buttons sitting in the middle of whatever vertical
     space is left -- easier to reach one-handed than either end of a
     tall phone screen. Two flex:1 spacers do the centering: the
     sidebar-inner::before pseudo-element right after the employee bar,
     and the real .nav-spacer element (index.html, originally there to
     push the employee bar to the bottom on DESKTOP) reused here at the
     opposite end instead -- equal flexible space on both sides of the
     button group centers it between them. */
  .employee-bar{order:-2;}
  .sidebar-inner::before{content:'';flex:1;order:-1;}
  .nav-spacer{order:8;}

  /* Place product and Search first -- per Stian, that's what a scanner
     is mostly used for. Same buttons as desktop, just reordered via
     `order` (no separate mobile markup to keep in sync). */
  .nav-btn[data-page="pages/place.html"]{order:2;}
  .nav-btn[data-page="pages/search.html"]{order:3;}
  .nav-btn[data-page="pages/log.html"]{order:4;}
  /* Storage map is hidden rather than reordered -- per Stian, nobody's
     realistically using the map from a phone navbar, so it's shelved on
     mobile entirely instead of just moved. */
  .nav-btn[data-page="pages/sweep.html"]{order:5;}
  .nav-btn[data-page="pages/map.html"]{display:none;}
  .nav-btn[data-page="pages/list.html"]{order:6;}
  .nav-btn[data-page="pages/settings.html"]{order:7;}

  /* overflow-x:hidden here (not just on #main-content/#main-content-peek
     individually) is what actually stops the swipe from ever producing a
     horizontal scrollbar -- #main-content-peek's OWN overflow:hidden only
     clips ITS children, it doesn't stop the (position:absolute) element's
     own transformed box from still counting toward main's scrollable
     area the way a position:static element's does. Clipping one level up
     at main catches both, regardless of which one is doing the moving. */
  /* --content-pad, not a plain padding:14px -- #main-content-peek below
     needs this exact same number for its own inset, and a shared custom
     property (inherited down to it automatically) is what keeps the two
     from silently drifting apart if this ever changes, the way a second
     hardcoded 14px could. */
  main{--content-pad:14px;padding:var(--content-pad);position:relative;overflow-x:hidden;}

  /* ---------- Swipe between pages (script.js) ----------
     Snapchat-style "card slides over" rather than both pages riding one
     filmstrip (Stian, after trying it themselves) -- #main-content (the
     page you're ON) is NEVER transformed by the swipe gesture, only
     #main-content-peek (the page you're going TO) is. It DOES fade its
     own opacity out over the drag's first ~20px (script.js) -- fully gone
     well before the incoming card has finished landing, rather than
     lingering underneath it (a page whose real card doesn't fill the
     whole height, e.g. Place product, otherwise stayed visible in the
     navy gaps around the incoming card right up until the actual handoff,
     then visibly "popped" away -- Stian). overflow-x:hidden still guards
     #main-content against unrelated horizontal overflow (a wide room in
     Storage map, say) ever widening the viewport into its own scrollbar
     -- unrelated to the swipe itself, but cheap insurance either way.
     touch-action:pan-y leaves vertical scrolling to the browser -- same
     convention as .sidebar's own pan-y above -- while horizontal drags
     are read by JS. */
  #main-content{
    overflow-x:hidden;
    touch-action:pan-y;
    transition:opacity .25s ease;
  }
  #main-content.page-dragging{transition:none;}

  /* The "new" card itself -- a live, non-interactive preview of whichever
     neighbor page a drag is currently headed toward (script.js), sliding
     from fully off-screen to fully covering #main-content as the drag
     progresses, then staying there once a commit hands its content over
     to #main-content underneath. Absolutely positioned over the exact
     same box #main-content occupies -- top/left/right:var(--content-pad)
     insets it exactly like #main-content's own padding does (main{
     position:relative} above is its containing block; a plain inset:0
     instead sat flush against main's own border/padding edge rather than
     being inset BY the padding, rendering every page a real 2*14px too
     wide inside the peek and then visibly narrowing the instant it
     became live #main-content -- the "resize jank" Stian first saw).
     HEIGHT is deliberately NOT the 4th inset value (bottom) that would
     naturally complete this box -- Safari (confirmed by Stian, doesn't
     reproduce in Chromium) fails to resolve an absolutely positioned
     element's height from top+bottom insets when the containing block's
     own height comes from being stretched through this many nested
     flex/grid layers (.app's grid row -> main's flex:1), instead treating
     it as effectively unbounded -- which, combined with
     .search-panel-wrap's margin-top/bottom:auto centering, pushed a short
     page's card WAY down inside that phantom extra height instead of
     centering it against the real, visible box. script.js sets height
     directly instead, copied from #main-content's own live
     getBoundingClientRect() -- a plain, already-correct measurement of a
     normal in-flow element, sidestepping the fragile computed-position
     chain entirely rather than trying to out-clever Safari's specific
     bug in it. z-index makes the covering explicit rather than relying
     on position:absolute already outranking #main-content's own
     position:static by default -- one less thing to break if either
     element's positioning ever changes later. No background of its own
     (a flat navy fill here read as permanently gray/flat over the real
     panel color even at rest, per Stian) -- #main-content fading itself
     out as the drag starts (below) is what actually keeps the old page
     from showing through now, so the peek doesn't need to be opaque on
     its own account. pointer-events:none -- a preview, not a second live
     page; nothing inside it (buttons, inputs, ...) should ever be
     reachable. transition/page-dragging is the same "off while JS drives
     it every pointermove frame, back on so a release can animate
     smoothly" trick used throughout this file (.mobile-topbar-title-wrap
     above, .sidebar earlier). */
  #main-content-peek{
    display:flex;
    flex-direction:column;
    min-width:0;
    position:absolute;
    top:var(--content-pad);
    left:var(--content-pad);
    right:var(--content-pad);
    z-index:1;
    overflow:hidden;
    pointer-events:none;
    transition:transform .25s ease;
  }
  #main-content-peek.page-dragging{transition:none;}

  /* ---------- Skeleton loader (script.js) ----------
     Only actually shown once a page load is taking long enough (script.js
     delays it) that leaving the outgoing page on screen -- or, mid-swipe,
     a blank gap -- would read as broken rather than loading. */
  .skeleton-loader{display:flex;flex-direction:column;gap:12px;padding:2px 0;}
  .skeleton-block{
    height:52px;
    border-radius:6px;
    background:linear-gradient(90deg, #333944 25%, #3f4652 37%, #333944 63%);
    background-size:400% 100%;
    animation:skeleton-shimmer 1.4s ease infinite;
  }
  .skeleton-block-lg{height:120px;}
  .skeleton-block-sm{width:60%;}
  @keyframes skeleton-shimmer{
    0%{background-position:100% 0;}
    100%{background-position:0 0;}
  }

  /* Each page's own in-page heading is replaced by .mobile-topbar-title
     above (script.js mirrors .page-title's text across on every page
     load) -- saves a phone the vertical space a second, redundant
     heading would cost. Hiding only the <h1> (not the whole
     .page-header) leaves page-specific header content alone -- e.g.
     Sweep Planner's "?" button, which lives in the same .page-header. */
  .page-title{display:none;}
  .content-panel{padding:16px;min-height:0;}

  /* Toolbars (Search/Overview/Movement log filter rows) stack full-width
     instead of several narrow controls squeezed onto one line. This also
     covers Overview's new include/exclude category inputs -- they stack
     under Search/Room filter one per row instead of cramming into the
     same line as desktop. */
  .toolbar > *{flex:1 1 100%;}
  /* Movement log's sort/user/type controls: instead of ALSO stacking
     full-width three separate times (the .toolbar > * rule above would
     otherwise do to .log-toolbar-icons' children too, if it weren't
     display:contents on desktop), they collapse to icon-only and share
     ONE row -- the same width budget one full-width toolbar item gets
     above, just split three ways instead of stacked (Stian: "they'll
     take up the space of what one button does today"). */
  .log-toolbar-icons{display:flex;gap:8px;}
  .log-icon-control{flex:1;justify-content:center;padding:9px 0;}
  /* container-type:inline-size scoped to HERE, not the base desktop rule
     -- on desktop .log-icon-control's own width comes purely from its
     content (icon+label), so a descendant sizing itself off that same
     container via cqi is a circular dependency the browser can't
     resolve cleanly (this is exactly what made desktop look "weird"
     after the first attempt). On mobile flex:1 gives it a size from the
     ROW's layout instead, independent of the label's own content, so
     the circularity doesn't exist here -- safe to scale the label
     against the button's real width. */
  .log-icon-control{container-type:inline-size;}
  .log-toolbar-label{
    font-size:clamp(9px, 8cqi, 13px);
    overflow:hidden;
    text-overflow:ellipsis;
    min-width:0;
  }
  /* Trying text back on for sort/user on mobile (Stian: "let's see if
     there's space") -- only the movement-type toggle's own label stays
     hidden, since a text label next to a 2-button pill doesn't read the
     same way an icon+label combo does for the other two. */
  .log-type-toggle-group .log-toolbar-label{display:none;}
  /* Padding on BOTH sides here, not just the left like desktop (Stian)
     -- the label that justified only-left-padding on desktop is hidden
     on mobile, so both buttons need their own breathing room from the
     chip's edges. */
  .log-type-toggle-group{padding:4px 8px;}
  /* flex-grow:0 here (desktop leaves flex:1 1 34px, so it stretches to
     fill the row) -- on the narrow mobile row, growing to fill a third
     of the toolbar made each button noticeably bigger than the other
     two icon buttons next to it (Stian: "still big there"). Fixed small
     size instead, centered as one pill within the group's own stretched
     chip (.log-icon-control{justify-content:center} above) rather than
     stretching itself. */
  .log-type-toggle{flex:0 0 24px;width:24px;height:22px;font-size:12px;}

  /* Overview's category chips: the desktop 16px remove circle is a small
     target to hit with a finger -- bigger both ways on mobile, and the
     chip's own padding/font grows a touch to match instead of looking
     mismatched next to a suddenly-larger remove button. */
  .category-chip{padding:7px 10px 7px 12px;font-size:13px;gap:8px;}
  .category-chip-remove{width:22px;height:22px;font-size:12px;}

  /* A visible edge around the table's own scroll region -- on mobile,
     with no scrollbar rendered by default and the table filling most of
     the screen, there was nothing marking where "scroll the list" ends
     and "scroll the page" begins. */
  .list-table-wrap{border:1px solid var(--line);border-radius:4px;}

  /* Storage map: 3D view dropped for now (see the commented-out line in
     map.js) -- hides the toggle button and its rotate/reset controls so
     there's no way to reach a view that isn't there for mobile yet. */
  #view-toggle-btn, #iso-controls{display:none !important;}

  /* View page's toolbar (.view-only, see map.html) only ever has the room
     switcher on mobile now -- both #view-toggle-btn and #iso-controls
     above are hidden, and Reset view is gone entirely. With nothing left
     on the right, the old justify-content:space-between had nothing to
     balance against and shoved the room switcher flush to the left edge
     instead of centering it. Doesn't touch Edit map's own .map-toolbar
     (+Room/Delete room/Print labels stay real, visible content there on
     mobile, so space-between still does the right thing for it). */
  .map-toolbar.view-only{justify-content:center;}

  /* Edit map's room-switcher: .content-panel is overflow:visible (lets
     pages that WANT to grow past it do so), so anything inside it that's
     too wide doesn't get clipped or scrolled -- it just visibly spills
     out past the panel's edge, which is what Stian was seeing. Three
     things needed together: the switcher itself must never be told it
     has more than 100% of its row to work with; its own two children
     (nav buttons + name input) need to be allowed to wrap onto a second
     line if they truly can't fit side by side, instead of forcing the
     row wider than the screen; and the buttons themselves shrink a
     little so that's rarely necessary in the first place. */
  /* Its own internal justify-content:center only centers the buttons
     WITHIN room-switcher's own (tightly content-sized) box -- that box
     still sits flush-left as a plain flex item inside .map-toolbar's
     space-between. flex:1 1 100% makes it take the whole toolbar row on
     mobile (room-actions/toolbar-actions wrap onto their own line below
     it instead), so centering its own content now actually centers it
     across the visible width of the bar, matching the view page. */
  .room-switcher{min-width:0;max-width:100%;flex:1 1 100%;flex-wrap:wrap;justify-content:center;}
  .room-nav-btn{width:44px;height:44px;font-size:22px;}
  #room-name-input{min-width:0;flex:1 1 140px;max-width:100%;}

  /* Map toolbar: +Room / Delete room / Print shelf labels are sized way up
     on desktop (.map-toolbar .btn.small above, ~2x normal .btn.small) for
     the scanner-friendly big-target feel -- too big to fit three across on
     a phone, so shrink them back down toward the normal .btn.small size. */
  .map-toolbar .btn.small{padding:7px 10px;font-size:12px;}
  .map-toolbar-actions .btn.small{height:auto;}

  /* Place product's Cancel/Add tag buttons are sized up on desktop (see
     .place-success-actions .btn.small above) -- shrink back to normal
     .btn.small size on mobile, same reasoning as the map toolbar buttons
     just above. */
  .place-success-actions .btn.small{height:auto;padding:7px 10px;display:inline-block;}

  /* ---------- Search: panel rests centered, rises on engagement (mobile only) ----------
     No branding mark here on mobile (Stian) -- once the panel below
     stops leaving a gap for it to show through, it'd just be floating
     free over whatever's below the (now full-height) panel instead of
     filling a real empty space. */
  .search-logo-mark{display:none;}
  /* Same margin-top/bottom:auto centering trick .place-panel uses, instead
     of desktop's fixed 420px-tall wrapper (which left the small collapsed
     panel pinned to the top with a big gap -- and, until the rule above,
     that background logo -- below it). min-height is set to MATCH
     .place-panel's own real height (240px, measured with its "no active
     user" banner hidden -- the common case), not left at the wrapper's
     own natural (much shorter, just the search bar) height -- centering
     a shorter block gives it a LARGER margin-top than a taller one gets,
     so the two pages' cards would land at different top offsets even
     though both are individually "centered" (Stian: "should have its top
     parallel with place product's"). Matching the height they're
     centered AGAINST is what makes the tops line up instead. margin-top
     is transitioned (not margin-bottom, which stays auto) since search.js
     only ever needs to animate the TOP edge -- rising to the page's top
     on engagement, dropping back to resting once idle again. */
  .search-panel-wrap{
    min-height:240px;
    margin-top:auto;
    margin-bottom:auto;
    transition:margin-top .3s ease;
  }

  /* ---------- Place: camera scan button (mobile only) ----------
     Un-hides it (base rule turns it off for desktop). No collapse/expand
     classes involved -- it's a permanent, always-tappable flex sibling of
     the input, same row as the find-space button (which keeps using its
     own normal showAvailBtn/hideAvailBtn collapse animation, place.js,
     completely independently -- the two buttons no longer share a slot,
     so there's nothing for them to fight over width-wise). */
  .place-camera-scan-btn{display:inline-flex;}

  /* Edit map, rack selected: the shelf-contents popover doesn't fit a
     phone screen usefully -- drop it and just pin rotate/delete full-width
     at the bottom, big enough to tap reliably. */
  .rack-side-panels .shelf-popover{display:none;}
  .rack-side-panels{
    position:fixed;
    bottom:0; left:0; right:0;
    width:auto;
    gap:0;
  }
  .rack-action-panel{
    border-radius:0;
    border-width:1px 0 0;
    box-shadow:0 -4px 18px rgba(0,0,0,.25);
    padding:10px 14px calc(10px + env(safe-area-inset-bottom));
  }
  .rack-action-btn{width:auto;flex:1;height:52px;}

  /* Storage map (view-only, not edit mode) as a full-screen "modal"
     instead of a small in-page panel (Stian) -- per Stian, a 380px-tall
     map squeezed into the normal scrolling page was "kind of useless" on
     a phone. .map-view-panel (added in map.html, scoped to THIS page
     only -- not edit-map, not any other .content-panel user) goes
     position:fixed covering the whole viewport, above the sidebar/top nav
     entirely. flex-direction:column + .map-frame's flex:1 below lets the
     map canvas claim essentially all the leftover vertical space after
     the toolbar/hint, instead of the old fixed 380px (see map.js's
     render2D, which now sizes the canvas to 100% of that flexed frame on
     mobile rather than a hardcoded px value).
     No "tap outside to close" -- edge-to-edge on purpose (maximizes map
     space), so there's no visible "outside" left to tap; the back button
     is the only exit, per Stian. */
  .map-view-panel{
    position:fixed;
    inset:0;
    z-index:40;
    background:var(--panel);
    display:flex;
    flex-direction:column;
    padding:14px calc(14px + env(safe-area-inset-right)) calc(14px + env(safe-area-inset-bottom)) calc(14px + env(safe-area-inset-left));
    overflow-y:auto;
  }
  .map-frame{flex:1;min-height:0;display:flex;}
  .map-canvas{flex:1;min-width:0;}
  /* Top-left, above the toolbar (which now starts lower to leave room for
     it, see the margin-top on .map-toolbar.view-only below) -- circular,
     same visual language as the rotate buttons elsewhere on this page. */
  .map-mobile-back-btn{
    display:flex;
    align-items:center;
    justify-content:center;
    position:absolute;
    top:calc(14px + env(safe-area-inset-top));
    left:calc(14px + env(safe-area-inset-left));
    width:40px;
    height:40px;
    border-radius:50%;
    border:1px solid var(--line);
    background:var(--white);
    color:var(--ink);
    font-size:20px;
    box-shadow:0 2px 8px rgba(0,0,0,.15);
    z-index:1;
  }
  .map-mobile-back-btn:active{border-color:var(--orange);}
  /* Room for the back button above it, since .map-view-panel's own
     position:fixed pulls the toolbar right up under the safe-area inset
     with nothing else reserving that space the way the normal page
     header did. */
  .map-toolbar.view-only{margin-top:48px;}

  /* Storage map (view-only, not edit mode): same mobile anchoring as
     edit-map's .rack-side-panels above -- a fixed 380px box pinned
     bottom:54px/right:54px (the desktop layout) runs off the left edge
     of a phone-width screen instead of just overflowing rightward, since
     380px + 54px right-offset is wider than the viewport itself. Pin it
     full-width at the bottom instead, same as edit-map's own popover. */
  #shelf-popover.shelf-popover{
    bottom:0; left:0; right:0;
    width:auto;
    max-height:60vh;
    border-radius:0;
    border-width:1px 0 0;
    box-shadow:0 -4px 18px rgba(0,0,0,.25);
    padding:14px 16px calc(14px + env(safe-area-inset-bottom));
    /* Explicit, and higher than .map-view-panel's z-index:40 above -- a
       position:fixed element with z-index:auto (this had neither before)
       still stacks BELOW any sibling that has a real z-index, regardless
       of DOM order, which would have buried this popover under the new
       full-screen map panel on the Storage map page specifically. */
    z-index:41;
  }

  /* Map: pan-y keeps native vertical scrolling AVAILABLE as a fallback
     (without granting native pinch-zoom) -- map.js's own touch handlers
     preventDefault() every touchmove they want to handle themselves
     (one-finger pan with room left to move, any two-finger pinch), and
     deliberately DON'T on a one-finger drag that's already hit the room's
     top/bottom edge, letting the browser's still-available pan-y take
     over for the rest of that touch. That's what makes a drag "chain"
     into scrolling the page once you run out of room to pan. Mobile-only
     -- desktop keeps its own mouse-drag/wheel-zoom pan system untouched. */
  .map-canvas{touch-action:pan-y;}

  /* Modals: never wider than the viewport, even the wider two
     (rack-modal/dims-modal, 380px on desktop). */
  .modal-card, #rack-modal .modal-card, #dims-modal .modal-card{width:92vw;max-width:380px;}
  .auth-card{width:92vw;max-width:340px;}

}

/* ---------- Overview + Movement log: own compact-layout breakpoint ----------
   Deliberately separate from the shared 768px every other mobile rule in
   this file uses (same idea as Search's own 990px breakpoint) -- per
   Stian, testing on an iPad (1024px landscape, so ABOVE 768px -- still
   "desktop" as far as every other page is concerned) showed these two
   tables' text visibly overflowing/escaping sideways before the shared
   breakpoint ever kicked in. Switches ONLY these two pages' table<->card
   swap earlier; the shared sidebar/nav collapse stays at 768px as
   always (per Stian: "not the navbar necessarily"). Both log.js and
   list.js always render BOTH the table rows and the card list
   unconditionally (no JS gate) -- this is a pure CSS toggle, matching
   the pattern search.js/map.js's own bespoke breakpoints already use.
   Overview's card view replaces the OLD approach of just hiding
   Room/Category/EAN off-screen -- per Stian, "no compromise on
   information being available" -- every field is on the card or one tap
   away (list.js's openDetail), nothing dropped or truncated.
   Container queries against .tables-panel's own width (see its
   container-type:inline-size rule above) rather than @media's viewport
   width -- these fire based on how cramped the panel itself actually is,
   regardless of sidebar width or page zoom. Placeholder thresholds below
   (700/800/900px) are a first pass, same as every other breakpoint in
   this file -- meant to be tuned against Stian's own iPad/phone rather
   than guessed exactly right on the first try.
   Log gets two intermediate tiers before the full mobile-card switch.
   Title-drop now fires WIDEST/first (Stian, revised: the tag pill was
   still getting squeezed in the gap before the title actually dropped --
   protecting the tag matters more than keeping "Placed"/"Removed" as
   text a moment longer, so that's the earlier/higher-priority tier now):
     1) the brand/title text, replaced by "model_name – leaf category"
        (model name unparenthesized once the title's gone) -- same idea
        as Overview's own tier just below, just at Log's own (wider)
        threshold since its Product column has less room to begin with
        (a fixed 40% of the table vs. Overview's unconstrained column)
     2) "Placed"/"Removed" text (the icon badge alone still says which)
   Shelf ID/Time/User are never hidden by either tier.
   Overview gets one tier of its own (Stian: the tag pill was easy to lose
   -- squeezed out by a long product title -- as the table narrowed
   toward the card breakpoint): the title drops in favor of "model_name –
   leaf category" (list.js's .list-row-fallback, only rendered when a
   model name exists so the cell never goes blank), and the tag itself is
   never touched by either page's tiers -- it just gets breathing room
   back once the title's gone. Room/Shelf ID/Category/Time added, being
   their own separate columns already, are unaffected either way. */
@container (max-width:950px){
  .log-cell-product.has-model .log-row-brand{display:none;}
  .log-cell-product.has-model .log-row-model::before,
  .log-cell-product.has-model .log-row-model::after{content:'';}
  .log-cell-product.has-model .log-row-category{display:inline;}
}
/* Same swap as the @container tier just above, but driven by log.js's
   applyProductCellTagOverflow per-row measurement instead of a blanket
   container-width breakpoint -- see list.js's matching .tag-priority
   rule for the full reasoning (Stian: tag visibility beats title, but
   not category+model_name visibility). */
.log-cell-product.has-model.tag-priority .log-row-brand{display:none;}
.log-cell-product.has-model.tag-priority .log-row-model::before,
.log-cell-product.has-model.tag-priority .log-row-model::after{content:'';}
.log-cell-product.has-model.tag-priority .log-row-category{display:inline;}
@container (max-width:900px){
  .log-type-text{display:none;}
}
@container (max-width:800px){
  .list-cell-product.has-model .list-row-title{display:none;}
  .list-cell-product.has-model .list-row-fallback{display:inline;}
}
/* Same swap as the @container tier just above, but driven by list.js's
   applyProductCellTagOverflow per-row measurement instead of a blanket
   container-width breakpoint -- a long title can force this even in a
   WIDE container, specifically because it's now competing with a tag
   group next to it (Stian: "we should prioritize tag visibility over
   TITLE visibility, but not category+model_name visibility"). Not
   scoped inside the @container block above since this needs to apply
   regardless of container width, whenever JS decides it's needed. */
.list-cell-product.has-model.tag-priority .list-row-title{display:none;}
.list-cell-product.has-model.tag-priority .list-row-fallback{display:inline;}
@container (max-width:700px){
  .log-table{display:none;}
  .log-mobile-list{display:flex;}
  .list-table-wrap{display:none;}
  .list-mobile-toolbar{display:flex;}
  .list-mobile-cards{display:flex;}
}

/* ---------- Portrait lock (mobile only) ----------
   Real orientation-lock (screen.orientation.lock()) only works inside an
   installed/fullscreen PWA, not a plain browser tab -- so instead, on a
   phone-sized *and* landscape viewport, #rotate-lock gets counter-rotated
   back to portrait and resized to fill the (now swapped) viewport
   dimensions. The layout itself never sees landscape at all.
   (pointer:coarse) keeps this off an ordinary widened desktop browser
   window, which can also match max-width but always has a mouse.
   html.true-landscape (not CSS's own `orientation` media feature) is what
   actually gates this now -- see updateTrueLandscapeClass in script.js.
   The media feature reflects the current VIEWPORT's aspect ratio, which
   briefly diverges from the phone's real orientation on some browsers
   when the on-screen keyboard opens (confirmed: Brave on iPhone), which
   used to rotate the WHOLE app any time you tapped an input. The JS class
   reads a hardware-level signal instead, immune to that. */
@media (max-width:768px) and (pointer:coarse){
  html.true-landscape, html.true-landscape body{width:100%;height:100%;overflow:hidden;margin:0;}
  html.true-landscape #rotate-lock{
    position:fixed;
    top:0;
    left:0;
    width:100vh;
    height:100vw;
    transform-origin:top left;
    transform:rotate(90deg) translateY(-100%);
    overflow-y:auto;
  }
}

/* ---------- Sweep Planner ---------- */
/* align-self:center (not margin:auto -- main is the flex container here,
   and combining width:100% with auto margins was fighting the max-width
   cap) centers this the same way .sweep-panel below it centers itself,
   so the tab row and the panel line up as one column instead of the tabs
   sitting flush left above a centered box. */
.sweep-tabs{display:flex;gap:8px;margin:0 0 16px;max-width:560px;align-self:center;width:100%;min-width:0;}
.sweep-tab{
  flex:1;
  padding:9px 10px;
  font-family:'IBM Plex Mono',monospace;
  font-size:12px;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  cursor:pointer;
  color:var(--ink-soft);
}
.sweep-tab.active{background:var(--ink);color:var(--white);border-color:var(--ink);}
.sweep-tab:disabled{opacity:.45;cursor:not-allowed;}
/* NOT max-width:560px here -- .sweep-panel/.sweep-batch-columns (applied
   alongside this class on every screen) already own that job with
   max-width:100% so they can actually shrink on a narrow viewport; a
   flat 560px here was WINNING the cascade over that (later in the
   file, equal specificity) and silently pinning every screen at 560px
   no matter how narrow the window got -- the real cause of the sweep
   pages refusing to shrink at all, not a flex min-width issue. */

.sweep-msg{
  font-family:'IBM Plex Mono',monospace;
  font-size:13px;
  padding:12px 14px;
  border-radius:3px;
  background:rgba(47,143,78,.12);
  border:1px solid rgba(47,143,78,.35);
  color:var(--ok);
}
.sweep-msg.place-msg-err{background:rgba(201,58,58,.12);border-color:rgba(201,58,58,.35);color:var(--err);}
.sweep-msg.place-msg-warn{background:rgba(232,96,28,.12);border-color:rgba(232,96,28,.35);color:var(--orange-dark);}

.sweep-dup-bar{
  display:flex;
  align-items:center;
  gap:10px;
  padding:10px 12px;
  background:rgba(232,96,28,.12);
  border:1px solid rgba(232,96,28,.35);
  border-radius:3px;
  font-size:13px;
}
.sweep-dup-bar span{flex:1;}

.sweep-batch-list{display:flex;flex-direction:column;gap:6px;}
.sweep-batch-row{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:10px;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  padding:9px 12px;
  transition:border-color .2s ease;
}
/* Points at the actual product that doesn't fit, instead of leaving the
   single "Space used" percentage to explain itself on its own (Stian --
   a flat 100% read as "the whole store is full" when really it was just
   this one shape/model with nowhere left to go). Recomputed on every
   refreshCapacity() call (see sweep.js), so it clears itself the moment
   the culprit item is removed or a move-toggle resolves it. */
/* Solid red fill now (not just an outline, per Stian -- the pulsing
   border alone didn't read as urgently as it needed to), so the pulse
   itself switches to a white glow instead of a border-color oscillation
   -- animating border-color between two reds would barely show up
   against an already-red background. */
@keyframes sweep-row-culprit-pulse{
  0%, 100%{box-shadow:0 0 0 0 rgba(255,255,255,0);}
  50%{box-shadow:0 0 0 3px rgba(255,255,255,.55);}
}
.sweep-batch-row.sweep-row-culprit{
  background:var(--err);
  border-color:var(--err);
  color:var(--white);
  animation:sweep-row-culprit-pulse 1.8s ease-in-out infinite;
}
/* Inverted (white bg, red text) rather than red-on-red so the qty badge
   still reads as its own distinct element against the now-solid-red row. */
.sweep-batch-row.sweep-row-culprit .sweep-qty-badge{background:var(--white);color:var(--err);}
.sweep-qty-badge.sweep-qty-badge-culprit{background:var(--err);color:var(--white);}
/* min-width:0 is load-bearing here -- without it a long brand+model name
   refuses to shrink below its own content width as a flex item, pushing
   the qty badge/remove button off the right edge on a narrow phone
   instead of wrapping. */
.sweep-batch-row-main{display:flex;align-items:center;gap:8px;font-size:13px;min-width:0;flex:1;}
.sweep-batch-name{overflow-wrap:anywhere;min-width:0;}
.sweep-qty-badge{
  font-family:'IBM Plex Mono',monospace;
  font-size:11px;
  color:var(--ink-soft);
  background:var(--panel);
  border-radius:3px;
  padding:1px 6px;
}
/* Knocks a scanned-twice item back down by ONE unit -- distinct from the
   red x, which always drops the whole line regardless of qty. Only shown
   once qty > 1 (see sweep.js) -- a qty:1 item has nothing left to
   decrement short of removing it outright, which the x already covers. */
.sweep-qty-controls{display:flex;align-items:center;gap:4px;}
.sweep-qty-decrement-btn{
  background:none;
  border:1px solid var(--line);
  border-radius:3px;
  color:var(--ink-soft);
  width:18px;
  height:18px;
  line-height:1;
  font-size:13px;
  cursor:pointer;
  display:flex;
  align-items:center;
  justify-content:center;
  padding:0;
}
.sweep-qty-decrement-btn:hover{border-color:var(--orange);color:var(--orange);}
.sweep-batch-row.sweep-row-culprit .sweep-qty-decrement-btn{border-color:var(--white);color:var(--white);}
.sweep-remove-btn{
  flex:none;
  width:26px; height:26px;
  border-radius:50%;
  border:none;
  background:var(--err);
  color:var(--white);
  line-height:1;
  cursor:pointer;
  display:flex;
  align-items:center;
  justify-content:center;
  padding:0;
}
.sweep-remove-btn svg{width:14px;height:14px;}
/* Row background is already --err while pulsing (see sweep-row-culprit-
   pulse above) -- invert to a white circle/red icon there instead of a
   red-on-red button that would disappear into it. */
.sweep-batch-row.sweep-row-culprit .sweep-remove-btn{background:var(--white);color:var(--err);}
/* Batch screen is TWO separate .content-panel boxes (settings, then the
   batch-list itself) instead of one panel with cards inside it, so the
   dark page background actually shows through as a gap between them
   (Stian: inner margin within one grey box didn't read as a real
   division). This wrapper is what centers/shrinks the pair as a column,
   the same job .sweep-panel does for Plan review/Walkthrough's single
   panel -- each child .content-panel below just gets the ordinary grey
   box treatment every other page's panel already has, no extra
   background of its own. */
.sweep-batch-columns{
  display:flex;
  flex-direction:column;
  gap:24px;
  flex:none;
  min-height:0;
  min-width:0;
  align-self:center;
  /* No margin-top:auto here (unlike .place-panel/.sweep-panel's own
     vertical-centering trick) -- Stian wants this sitting right under
     the tabs, not centered in the whole leftover height of the page.
     margin-bottom:auto still soaks up any extra space at the BOTTOM so
     it doesn't stretch to fill it. */
  margin-bottom:auto;
  width:560px;
  max-width:100%;
}
/* Overriding the base .content-panel rule's flex:1/min-height:600px --
   without this each of the two boxes above would try to grow to fill
   .sweep-batch-columns's height and inherit a 600px floor, same reason
   .place-panel/.settings-panel override it for their own single panel.
   min-width:0 breaks the same "flex item won't shrink below its
   content's min-content width" default that was pushing these panels
   (and the whole page) wider than a phone screen even after their
   CONTENTS had proper overflow-wrap -- the panel itself was still
   refusing to shrink below whatever its widest child wanted. */
.sweep-settings-panel, .sweep-batch-panel{flex:none;min-height:0;min-width:0;}
.sweep-settings-panel{display:flex;flex-direction:column;gap:16px;min-width:0;}
.sweep-settings-head{display:flex;min-width:0;}
.sweep-settings-toggles{display:flex;flex-direction:column;gap:12px;flex:1;min-width:0;}
/* Generate plan's own lane between the two panels -- no explicit width
   here, it just inherits the 560px .sweep-batch-columns already gives
   every other direct child via the flex column's default cross-axis
   stretch (same as .sweep-settings-panel/.sweep-batch-panel above), which
   is what makes it line up flush with the panels on both sides. The
   button itself is centered WITHIN that lane (justify-content:center on
   the inner wrapper), not stretched to fill it. CSS-grid accordion (0fr
   -> 1fr) so appearing/disappearing animates instead of an instant pop
   (Stian) -- same technique as Edit rack's category-lock fields
   (.story-category-lock-fields), toggled by sweep.js's renderBatchList
   adding/removing .visible as the batch goes from empty to non-empty and
   back. */
.sweep-generate-wrap{
  display:grid;
  grid-template-rows:0fr;
  min-width:0;
  /* .sweep-batch-columns' own gap:24px still reserves its full 24px both
     above AND below this element regardless of how tall ITS OWN box
     currently is (flex/grid `gap` isn't part of an item's box) -- without
     these negative margins to cancel that out, hidden meant "no button,
     but still a much-too-wide 48px gap between the two panels" instead of
     them sitting at their normal, single-gap distance. Transitioning them
     to 0 in step with grid-template-rows growing is what makes the two
     panels smoothly drift apart as this grows in, instead of the gap
     jumping open first and the button fading into it after. */
  margin:-24px 0;
  transition:grid-template-rows .22s ease, margin .22s ease;
}
.sweep-generate-wrap.visible{grid-template-rows:1fr;margin:0;}
.sweep-generate-wrap-inner{
  overflow:hidden;
  min-height:0;
  display:flex;
  justify-content:center;
  opacity:0;
  transition:opacity .15s ease;
}
.sweep-generate-wrap.visible .sweep-generate-wrap-inner{opacity:1;transition:opacity .2s ease .08s;}
.sweep-switch-row{display:flex;align-items:center;justify-content:space-between;gap:10px;border-radius:4px;padding:4px 6px;margin:-4px -6px;}
/* Slow pulsing glow hinting "this toggle would actually help right now"
   -- only added when the capacity warning's wouldResolveWithMoves says
   turning it on would genuinely resolve something (see
   setCapacityWarning), never just because something's stuck in general. */
/* Background wash, not a border -- capped at 50% alpha (Stian) so it
   never gets in the way of reading the label or seeing the switch state,
   just a slow ambient nudge toward it. */
@keyframes sweep-hint-pulse{
  0%, 100%{background-color:rgba(232,96,28,0);}
  50%{background-color:rgba(232,96,28,.5);}
}
.sweep-switch-row.sweep-hint-pulse{animation:sweep-hint-pulse 1.8s ease-in-out infinite;}
.sweep-switch-label{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--ink);}
/* Standard checkbox-driven toggle switch -- the real <input type=
   checkbox> is visually hidden (not display:none, which would drop it
   from tab order) and the track/knob are plain siblings styled off its
   :checked state, same trick browsers have used for this for years. */
.sweep-switch{position:relative;display:inline-block;width:38px;height:22px;flex-shrink:0;cursor:pointer;}
.sweep-switch input{position:absolute;opacity:0;width:100%;height:100%;margin:0;cursor:pointer;}
.sweep-switch-track{
  position:absolute;
  inset:0;
  background:var(--line);
  border-radius:22px;
  transition:background-color .15s ease;
  pointer-events:none;
}
.sweep-switch-track::before{
  content:"";
  position:absolute;
  height:16px;
  width:16px;
  left:3px;
  top:3px;
  background:var(--white);
  border-radius:50%;
  transition:transform .15s ease;
}
.sweep-switch input:checked + .sweep-switch-track{background:var(--orange);}
.sweep-switch input:checked + .sweep-switch-track::before{transform:translateX(16px);}
.sweep-switch input:focus-visible + .sweep-switch-track{outline:2px solid var(--orange);outline-offset:2px;}
.sweep-empty{font-size:13px;color:var(--ink-soft);padding:6px 0;}
.sweep-section-title{font-size:13px;margin:14px 0 6px;color:var(--ink-soft);text-transform:uppercase;letter-spacing:.04em;}
/* Plus icon next to "Queue:" that opens the add-product modal (Stian) --
   replaces what used to be an always-visible input row above the list. */
.sweep-queue-head{display:flex;align-items:center;justify-content:space-between;margin:14px 0 6px;}
.sweep-queue-head .sweep-section-title{margin:0;}
.sweep-add-btn{
  flex:none;
  width:26px;height:26px;
  border-radius:50%;
  border:1px solid var(--line);
  background:var(--white);
  color:var(--ink);
  display:flex;align-items:center;justify-content:center;
  cursor:pointer;
}
.sweep-add-btn svg{width:16px;height:16px;}
.sweep-add-btn:hover{background:var(--orange);border-color:var(--orange);color:var(--white);}

.sweep-page-header-actions{display:flex;align-items:center;gap:10px;}

/* Mode picker (Placing vs Spacing) -- the very first thing shown when
   starting fresh, see sweep.html's comment. Two equal-weight cards, not
   a settings toggle, since this is a one-time fork in the road rather
   than an in-flow option. min-height:auto overrides .content-panel's own
   600px (Stian: the picker's own content is nowhere near that tall, so
   inheriting it left a huge stretch of empty panel below the two cards). */
.sweep-mode-screen, #sweep-spacing-screen{min-height:auto;}
.sweep-mode-screen .sweep-section-title{margin-top:0;text-align:center;font-size:15px;}
.sweep-mode-cards{display:flex;gap:16px;flex-wrap:wrap;margin-top:12px;}
.sweep-mode-card{
  flex:1 1 220px;text-align:left;cursor:pointer;
  background:var(--panel-2);border:2px solid var(--line);border-radius:6px;
  padding:20px;display:flex;flex-direction:column;gap:6px;
  transition:border-color .15s ease, background-color .15s ease;
}
.sweep-mode-card:hover{border-color:var(--orange);background:rgba(232,96,28,.08);}
.sweep-mode-card-title{
  font-family:'Oswald',sans-serif;font-weight:600;font-size:18px;
  text-transform:uppercase;color:var(--ink);
}
.sweep-mode-card-desc{font-size:13px;color:var(--ink-soft);line-height:1.4;}
.sweep-mode-back-btn{margin-bottom:10px;}
/* "Where is this?" shelf popup (Stian) -- same IsoRender scene Search's
   own minimap uses, one shelf at a time, panned/zoomed/blinked to it (see
   sweep.js's openSweepShelfModal). Reuses Search's OWN mobile-minimap-
   modal/-panel/-view/-view-btn component verbatim (style.css, near
   .rr-shelf) rather than a sweep-specific lookalike -- only the shelf-
   code title (#sweep-shelf-modal-title, plain .modal-title) and this
   highlight-scoping rule are sweep's own. */
#sweep-shelf-modal-minimap .shelf-result-highlight{
  stroke:var(--ok) !important;
  stroke-width:2.5px;
}
.sweep-step-list{display:flex;flex-direction:column;gap:6px;}
.sweep-step-card{
  background:var(--white);
  border:1px solid var(--line);
  border-radius:3px;
  padding:9px 12px;
  font-size:13px;
  display:flex;
  align-items:center;
  gap:8px;
  /* A long brand+model name has no spaces to wrap on -- without this it
     forced the whole card (and the page under it) wider than the phone's
     screen instead of just wrapping onto a second line. */
  overflow-wrap:anywhere;
}
/* .sweep-step-text, NOT a bare trailing text node -- see sweep.js's
   renderReview comment on the unplaceable-card template. A raw text node
   sitting next to <strong> can't be given flex:1/min-width:0 (there's no
   way to select it), so it refused to shrink on a narrow screen and forced
   <strong> down to near-zero instead, wrapping the product name one
   letter per line. */
.sweep-step-card > strong, .sweep-step-text{flex:1;min-width:0;}
.sweep-step-card.sweep-status-unplaceable{border-color:rgba(201,58,58,.4);}
/* A product added on Plan review since the last Update plan click -- see
   sweep.js's pendingAddedBatchItemIds. Blue border + greyed out signals
   "this card's step is stale until you hit Update", even though the server
   already gave it a real shelf assignment behind the scenes. */
.sweep-step-card.sweep-queue-card--uncommitted{border-color:var(--info);background:rgba(47,111,168,.06);opacity:.6;}
.sweep-step-status{
  font-family:'IBM Plex Mono',monospace;
  font-size:11px;
  color:var(--ink-soft);
  text-transform:uppercase;
  flex-shrink:0;
}
/* Same yellow badge used everywhere else a shelf code appears (Edit Map's
   rack editor, the Storage Map popover, Search's own result rows -- see
   .story-header-code/.popover-story-code/.rr-shelf) -- a plain button so
   it's clickable (see sweep.js), not just styled text. Named
   .sweep-review-shelf-badge, NOT .sweep-shelf-badge -- that name was
   already taken by the walkthrough card's own (differently styled,
   non-clickable) shelf badge further down this file; reusing it here by
   accident silently merged the two, which also fed into a JS function-
   name collision (see sweep.js's reviewShelfBadgeHtml comment) that was
   the actual cause of these showing raw numeric shelf ids. */
.sweep-review-shelf-badge{
  font-family:'IBM Plex Mono',monospace;
  font-weight:600;
  font-size:12px;
  color:var(--ink);
  letter-spacing:.02em;
  white-space:nowrap;
  background:#cfa90f;
  border:none;
  border-radius:3px;
  padding:2px 7px;
  cursor:pointer;
  flex-shrink:0;
}
/* Pending/blocked place-or-unplaceable step -- always removable, per
   Stian, rather than a plain "pending" status label nothing could be
   done with. Reuses removeReviewBatchItem's own DELETE+Update round trip
   (sweep.js), same as the batch list's own remove button on Scan. */
.sweep-step-remove-btn{
  flex:none;
  width:22px; height:22px;
  border-radius:50%;
  border:none;
  background:var(--err);
  color:var(--white);
  line-height:1;
  cursor:pointer;
  display:flex;
  align-items:center;
  justify-content:center;
}
.sweep-step-remove-btn svg{width:12px;height:12px;}
.sweep-step-remove-btn:hover{background:#a83030;}
/* Confirmed (already physically placed/moved during the walkthrough) --
   a plain, unclickable check instead of the same removable X, since
   undoing a real placement isn't what this button was ever for. */
.sweep-step-check{
  flex:none;
  width:22px; height:22px;
  border-radius:50%;
  background:var(--ok);
  color:var(--white);
  font-size:13px;
  line-height:1;
  display:flex;
  align-items:center;
  justify-content:center;
}
/* Only when there's actually something in it (see sweep.js's
   renderReview, which toggles this) -- an always-red heading over an
   empty "None." would read as a standing warning about nothing. */
.sweep-section-title.sweep-title-danger{color:var(--err);}
.sweep-step-list.sweep-title-danger .sweep-step-card{border-color:rgba(201,58,58,.4);background:rgba(201,58,58,.06);}
/* Queue (Plan+Move merged, drag-to-reorder) -- see queueCardHtml/
   wireQueueDrag in sweep.js. The card body stacks the linked move line
   (if any) above the place line instead of sitting inline like the old
   single-line Plan/Move cards did, since a card can now show two steps. */
.sweep-queue-card-body{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;overflow-wrap:anywhere;}
.sweep-queue-move-line{font-size:11px;color:var(--ink-soft);}
.sweep-drag-handle{
  flex:none;
  width:22px;height:22px;
  display:flex;align-items:center;justify-content:center;
  color:var(--ink-soft);
  background:none;
  border:none;
  padding:0;
  cursor:grab;
  touch-action:none; /* otherwise a touch-drag also tries to scroll the page underneath it */
}
.sweep-drag-handle svg{width:16px;height:16px;}
.sweep-drag-handle:active{cursor:grabbing;}
/* Confirmed steps: an empty same-width spacer, not a hand icon that
   implies something draggable that a drop on /reorder would then reject. */
.sweep-drag-handle-locked{cursor:default;}
/* Drag visuals now come from SortableJS (see wireQueueDrag in sweep.js,
   forceFallback mode) -- .sweep-queue-card--dragging is its fallback clone
   that follows the pointer, .sweep-queue-placeholder is the real dragged
   card itself, left in the list at the current drop target (its
   ghostClass). Fully invisible (Stian) -- the floating clone already
   shows what's being placed, so a second, dimmed copy sitting in the gap
   just read as clutter. opacity:0 keeps its layout space (so the list
   doesn't jump) without showing anything.  */
.sweep-queue-card--dragging{
  box-shadow:0 6px 18px rgba(0,0,0,.2);
  opacity:.96;
}
.sweep-queue-placeholder{
  opacity:0;
}
/* Grid, not flex -- three equal columns so Cancel/Update/Start always land
   left/middle/right in that fixed order, identically on mobile and desktop
   (Stian: "same left, middle, right look on both"). */
.sweep-review-actions{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:16px;}
.sweep-action-btn{display:flex;align-items:center;justify-content:center;gap:6px;white-space:normal;text-align:center;}
.sweep-action-btn svg{width:16px;height:16px;flex-shrink:0;}
.btn.info{background:var(--info);border:1px solid var(--info);color:var(--white);}
.btn.info:hover{background:#25588a;}
.btn.success{background:var(--ok);border:1px solid var(--ok);color:var(--white);}
.btn.success:hover{background:#256f3d;}
/* Update plan starts disabled (nothing to update yet) and only lights up
   once something's actually changed -- see sweep.js's setPlanDirty. The
   plain .btn:disabled grey would otherwise lose to .btn.info's own
   background at equal specificity since .info is declared later. */
.btn.info:disabled{background:var(--ink-soft);border-color:var(--ink-soft);}

/* Live "available space" gauge on the batch screen -- fillPercent comes
   from the server's dry-run planner (see sweep.js's /capacity route), the
   color interpolation itself is done in JS (setCapacityBar) since it's a
   continuous green->red blend, not a fixed set of states a class-per-tier
   approach would suit. */
.sweep-capacity-label{
  display:flex;
  justify-content:space-between;
  font-family:'IBM Plex Mono',monospace;
  font-size:11px;
  color:var(--ink-soft);
  text-transform:uppercase;
  margin-bottom:4px;
}
.sweep-capacity-track{
  height:10px;
  border-radius:5px;
  background:var(--panel);
  border:1px solid var(--line);
  overflow:hidden;
}
.sweep-capacity-fill{
  height:100%;
  width:0%;
  background:var(--ok);
  transition:width .25s ease, background-color .25s ease;
}
/* Brief flash whenever the fill amount changes (added/removed by JS, see
   setCapacityBar) -- the width/background transition above already makes
   the fill itself glide smoothly, this is just an extra "something just
   happened" pulse layered on top of that. */
@keyframes sweep-capacity-pulse{
  0%{box-shadow:0 0 0 0 rgba(0,0,0,.25);}
  100%{box-shadow:0 0 0 6px rgba(0,0,0,0);}
}
.sweep-capacity-pulse{animation:sweep-capacity-pulse .4s ease-out;}

/* Red-outline "Yes, cancel" on the duplicate-scan bar -- same shape as
   .place-tag-btn's outline treatment, just in the danger color instead of
   info, since this is the "undo the accidental scan" action. */
.sweep-dup-cancel-btn{background:transparent;border:1px solid var(--err);color:var(--err);}
.sweep-dup-cancel-btn:hover{background:rgba(201,58,58,.1);}

/* Walkthrough: a scrollable, swipeable stack of Place-product-style
   cards, one per step (see sweep.js's renderWalkList) -- plain native
   overflow scrolling is what gives the "swipe up and down" gesture on
   touch, no extra JS needed for that part. NOT scroll-snap (tried first,
   removed -- Stian: felt jagged/stuttery on a mouse wheel, snap points
   fighting small scroll deltas near each card boundary). Settling on the
   active card is handled by scrollToActiveCard() instead (only fires
   when the active step actually changes, see renderWalkthrough), which
   doesn't fight a manual scroll/swipe in progress the way a permanent
   snap point does. */
.sweep-walk-scroll{
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:16px;
  max-height:min(640px, 70vh);
  overflow-y:auto;
  overflow-x:hidden;
  padding:4px 2px 20px;
  -webkit-overflow-scrolling:touch;
  width:100%;
  min-width:0;
}
.sweep-walk-card-item{
  flex-shrink:0;
  background:var(--white);
  border:1px solid var(--line);
  border-radius:4px;
  padding:22px;
  position:relative;
  transition:opacity .2s ease;
  width:100%;
  min-width:0;
  box-sizing:border-box;
}
.sweep-walk-card-item.is-active{border-color:var(--orange);box-shadow:0 0 0 2px rgba(232,96,28,.15);}
/* Locked (dependency not confirmed yet) and merely-not-selected-right-now
   cards both read as "not your current focus" -- greyed either way, per
   Stian's request; is-selectable just adds pointer/hover on top of that
   same greyed look for the ones you CAN jump to. */
.sweep-walk-card-item.is-waiting{opacity:.45;}
.sweep-walk-card-item.is-locked{cursor:not-allowed;}
.sweep-walk-card-item.is-selectable{cursor:pointer;}
.sweep-walk-card-item.is-selectable:hover{opacity:.7;border-color:var(--orange);}
.sweep-walk-card-item.is-trouble{opacity:.6;border-color:rgba(201,58,58,.4);}
.sweep-walk-card-item.is-done{opacity:.7;}
.sweep-walk-done-overlay{
  position:absolute;
  top:10px;
  right:10px;
  width:26px;
  height:26px;
  border-radius:50%;
  background:var(--ok);
  color:var(--white);
  display:flex;
  align-items:center;
  justify-content:center;
  font-size:15px;
  font-weight:600;
}
.sweep-walk-card-head{display:flex;align-items:baseline;gap:8px;margin-bottom:6px;}
.sweep-walk-card-type{font-family:'IBM Plex Mono',monospace;font-size:11px;text-transform:uppercase;color:var(--ink-soft);}
.sweep-walk-card-name{font-size:14px;font-weight:600;color:var(--ink);overflow-wrap:anywhere;}
.sweep-walk-card-summary{font-size:13px;color:var(--ink-soft);overflow-wrap:anywhere;}
/* One task ("confirm the product, THEN confirm the shelf") stays in ONE
   card at all times (Stian) -- both halves render together here, not one
   replacing the other. .sweep-walk-phase is just the outer stack; each
   half is its own .sweep-walk-subtask, separated by a thin divider once
   there's a second one to divide from. */
.sweep-walk-phase{display:flex;flex-direction:column;}
.sweep-walk-subtask + .sweep-walk-subtask{border-top:1px solid var(--line);margin-top:14px;padding-top:14px;}
.sweep-walk-subtask.is-subtask-locked{opacity:.4;}
.sweep-walk-subtask.is-subtask-done{opacity:.65;}
.sweep-walk-subtask-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px;}
.sweep-walk-subtask-title{font-size:16px;font-weight:600;display:flex;align-items:center;gap:8px;flex-wrap:wrap;overflow-wrap:anywhere;}
.sweep-walk-subtask-check{font-family:'IBM Plex Mono',monospace;font-size:13px;font-weight:600;color:var(--ok);}
/* Last-3-digits EAN hint, sat beside the "Scan ..." title -- fake bars
   (a real barcode needs a valid full checksum, a 3-digit fragment
   doesn't have one), just enough visual language to read as "barcode"
   at a glance next to the digits that actually matter here. */
.sweep-ean-hint{display:inline-flex;align-items:center;gap:6px;font-weight:400;}
.sweep-ean-hint-bars{
  display:inline-block;
  width:22px;
  height:14px;
  background:repeating-linear-gradient(90deg, #000 0, #000 2px, transparent 2px, transparent 4px);
}
.sweep-ean-hint-digits{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ink-soft);}
/* Narrower than a normal .field-input on purpose (Stian) -- this is a
   single scanned code, not a free-typed sentence, so a full-width input
   just wastes space and looks oversized next to everything else on the
   card. */
/* flex:1 + min-width:0 (not just max-width) is what actually lets the
   input SHRINK to share the row with the picture/badge on a narrow
   screen -- without min-width:0, a text input's default intrinsic
   min-width refuses to shrink past its content size, which is what was
   forcing flex-wrap to drop it onto its own line on mobile instead of
   narrowing it. */
.sweep-walk-input-narrow{max-width:220px;flex:1;min-width:0;}
/* The narrow input frees up real width on the card -- this is what fills
   it: a shelf-code badge styled after the actual printed shelf labels
   (see .print-label-title.shelf-id-label), so the destination/pickup
   shelf stays visible at a glance next to whatever's being scanned.
   flex-wrap:nowrap is deliberate (Stian) -- the picture/badge and the
   input must always share one line, even on a narrow phone screen. */
.sweep-walk-phase-row{display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}
.sweep-walk-phase-row > .rr-thumb,
.sweep-walk-phase-row > .rr-thumb-icon,
.sweep-walk-phase-row > .sweep-shelf-badge{flex-shrink:0;}
.sweep-shelf-badge{
  background-color:#cfa90f;
  color:#000;
  border:2px solid #000;
  border-radius:4px;
  font-family:'IBM Plex Mono',monospace;
  font-weight:700;
  font-size:20px;
  letter-spacing:.02em;
  padding:8px 16px;
  line-height:1;
  cursor:pointer;
}
.sweep-walk-toolbar{display:flex;justify-content:space-between;gap:8px;margin-top:16px;max-width:560px;margin-left:auto;margin-right:auto;}

@media (max-width:768px){
  .sweep-walk-scroll{max-height:min(520px, 65vh);}
  /* Shrunk down (not wrapped -- see .sweep-walk-phase-row's own
     flex-wrap:nowrap above) so the badge/picture, gap, and a still-usable
     input all actually fit on one line on a real phone width. */
  .sweep-walk-phase-row{gap:8px;}
  .sweep-shelf-badge{font-size:15px;padding:6px 10px;}
  /* Card padding trimmed to match .content-panel's own mobile padding
     reduction (16px) -- 22px was eating a lot of a narrow phone's width
     for pure whitespace. */
  .sweep-walk-card-item{padding:14px;}
  /* "Jump to current step" + "Cancel plan" side by side could overflow a
     narrow phone; letting them wrap is simpler than shrinking text. */
  .sweep-walk-toolbar{flex-wrap:wrap;}
  .sweep-tab{font-size:11px;padding:8px 6px;}
  /* Plan review's action row is a fixed 3-column grid at any width (see
     .sweep-review-actions) -- on a narrow phone the buttons just need
     smaller padding/font so "Cancel plan"/"Update plan"/"Start placing"
     comfortably fit (wrapping to two lines is fine, see .sweep-action-btn's
     white-space:normal) rather than overflowing their column. */
  .sweep-review-actions .btn{padding:10px 6px;font-size:11px;}
  .sweep-action-btn svg{width:14px;height:14px;}
}

/* ---------- "How does the walkthrough order work?" explainer ----------
   A "?" icon in the page-header corner (Stian) opening a modal with a
   looping animation + plain-language text, explaining that the
   walkthrough now follows SCAN order (see scanOrderSortKey in
   server/routes/sweep.js) instead of shelf geometry. */
.sweep-page-header{display:flex;align-items:center;justify-content:space-between;gap:12px;}
.sweep-page-title-group{display:flex;align-items:center;gap:12px;}
/* .btn.ghost's default text color (var(--navy-2)) assumes it's sitting
   on a light .content-panel background -- true everywhere else it's
   used, but this button sits directly in the page header, on the same
   dark navy as the page itself, which made it read as almost invisible
   (Stian). Same fix/reasoning as .employee-bar .btn.ghost elsewhere in
   this file, for the same underlying reason. Scoped to .sweep-page-
   title-group, NOT .sweep-page-header-actions -- the button moved next
   to the title (Stian) after this rule was first written, and the
   selector wasn't updated then, silently breaking this exact fix again. */
.sweep-page-title-group .btn.ghost{color:var(--white);border-color:#4a4f57;}
.sweep-how-btn{
  width:30px;height:30px;border-radius:50%;flex-shrink:0;
  border:2px solid var(--white);color:var(--white);background:transparent;
  font-family:'Oswald',sans-serif;font-weight:600;font-size:15px;
  display:flex;align-items:center;justify-content:center;cursor:pointer;
  opacity:.75;transition:opacity .15s ease, background-color .15s ease;
}
.sweep-how-btn:hover{opacity:1;background:rgba(243,242,239,.12);}

.sweep-how-card{width:400px;max-width:92vw;align-items:center;text-align:center;}
.sweep-how-card .modal-title{text-align:center;}
.sweep-how-anim-wrap{
  width:100%;background:var(--panel-2, #c9c9c5);border-radius:6px;
  padding:10px 6px;margin-bottom:14px;
}
.sweep-how-anim{width:100%;height:auto;display:block;}
.sweep-how-box rect{stroke-width:2;}
.sweep-how-box text{
  font-family:'IBM Plex Mono',monospace;font-weight:700;font-size:22px;
  fill:var(--ink);text-anchor:middle;dominant-baseline:middle;
}
/* One 11.5s loop, five stretches (Stian: not everything synced together
   per item -- scan first, THEN check/fly-off, with pauses throughout so
   the loop doesn't feel rushed):
     Fade-in   0.0s-0.4s   the scene fades in, boxes/list at rest.
     (delay)   0.4s-0.9s   nothing happens yet -- lets the fade-in
                           actually finish and the boxes read as "back in
                           place" BEFORE scanning starts, instead of the
                           scan beginning mid-fade.
     Phase A   0.9s-5.5s   scan -- beam sweeps box to box, each box glows
                           as it's hit and its list row pops up right
                           after. Boxes stay put; nothing checked yet.
     Phase B   5.5s-6.0s   pause -- everything holds.
     Phase C   6.0s-10.0s  check -- each row gets its checkmark, and AS it
                           checks, that row's box flies off screen (its
                           own direction, "stashed away").
     Phase D  10.0s-11.2s  settle -- everything checked/gone, holds still
                           for a beat before the loop resets.
   11.2s-11.5s/next 0s-0.4s: the whole <svg> fades out then back in right
   at the loop seam (see .sweep-how-anim), so every element's silent
   snap back to its home/hidden state is hidden behind that fade instead
   of visibly snapping -- this is what lets every element's keyframes
   just define "home" at both 0% and 100%. */
.sweep-how-anim{animation:sweep-how-scene-fade 11.5s ease-in-out infinite;}
@keyframes sweep-how-scene-fade{0%,100%{opacity:0;}4%,97%{opacity:1;}}

.sweep-how-box rect{stroke-width:2;}
.sweep-how-box text{
  font-family:'IBM Plex Mono',monospace;font-weight:700;font-size:18px;
  fill:var(--ink);text-anchor:middle;dominant-baseline:middle;
}
.sweep-how-row rect{fill:var(--panel);stroke:var(--line);stroke-width:1.5;}
.sweep-how-row text{
  font-family:'IBM Plex Mono',monospace;font-weight:700;font-size:7px;
  fill:var(--ink);text-anchor:middle;dominant-baseline:middle;
}
.sweep-how-rowcheck{fill:var(--ok);stroke:var(--white);stroke-width:1.5;transform-box:fill-box;transform-origin:center;}

/* Scan beam (Phase A only, 0.9s-5.5s of 11.5s) -- teleports (invisibly,
   between each item's window) to sit over whichever box is currently
   being scanned, moving in BOTH x (column) and y (row) now that boxes
   sit in two rows. Column centers: 66, 160, 254. Row offset: 0 for the
   top row (1,2,3 -- boxes already sit at the beam's home y), +34 for the
   bottom row (4,5,6). Visiting 1,2,3 THEN 4,5,6 is what makes the beam
   visibly sweep the top row before dropping to the bottom row. Item
   start times: 4, 11, 19, 26, 33, 41 (i.e. Phase A's 0.9s delay baked
   into every number below). Off before Phase A starts and for the rest
   of the loop after it ends (Phase B/C/D -- no scanning happening then). */
.sweep-how-scanbeam{animation:sweep-how-beam 11.5s linear infinite;}
@keyframes sweep-how-beam{
  0%,3%{transform:translate(66px,0);opacity:0;}
  4%,8%{transform:translate(66px,0);opacity:1;}
  9%{transform:translate(66px,0);opacity:0;}
  11%,15%{transform:translate(160px,0);opacity:1;}
  16%{transform:translate(160px,0);opacity:0;}
  19%,23%{transform:translate(254px,0);opacity:1;}
  24%{transform:translate(254px,0);opacity:0;}
  26%,30%{transform:translate(66px,34px);opacity:1;}
  31%{transform:translate(66px,34px);opacity:0;}
  33%,37%{transform:translate(160px,34px);opacity:1;}
  38%{transform:translate(160px,34px);opacity:0;}
  41%,44%{transform:translate(254px,34px);opacity:1;}
  45%,100%{transform:translate(254px,34px);opacity:0;}
}

/* Each box: glow (scan pulse, Phase A, on the rect) + fly-off (Phase C,
   translate/fade on the whole group so the number rides along) -- a
   different exit direction per box so it visibly reads as 6 separate
   items being "stashed away" rather than all vanishing the same way.
   Phase A start times: 4, 11, 19, 26, 33, 41 (glow only, box stays put).
   Phase C start times: 52, 58, 63, 70, 76, 81 (fly-off, synced with that
   row's checkmark below) -- fully decoupled from the Phase A numbers, so
   scanning and flying-away never overlap in time. Everything that's
   already flown off holds gone through 97% (see Phase D / the settle
   pause), then resets at 100%, masked by the scene fade. */
#sweep-how-box-1{animation:sweep-how-box1-move 11.5s ease-in infinite;}
#sweep-how-box-2{animation:sweep-how-box2-move 11.5s ease-in infinite;}
#sweep-how-box-3{animation:sweep-how-box3-move 11.5s ease-in infinite;}
#sweep-how-box-4{animation:sweep-how-box4-move 11.5s ease-in infinite;}
#sweep-how-box-5{animation:sweep-how-box5-move 11.5s ease-in infinite;}
#sweep-how-box-6{animation:sweep-how-box6-move 11.5s ease-in infinite;}
@keyframes sweep-how-box1-move{0%,52%{opacity:1;transform:translate(0,0);}58%,97%{opacity:0;transform:translate(-70px,-40px);}100%{opacity:1;transform:translate(0,0);}}
@keyframes sweep-how-box2-move{0%,58%{opacity:1;transform:translate(0,0);}64%,97%{opacity:0;transform:translate(-5px,-60px);}100%{opacity:1;transform:translate(0,0);}}
@keyframes sweep-how-box3-move{0%,63%{opacity:1;transform:translate(0,0);}70%,97%{opacity:0;transform:translate(70px,-40px);}100%{opacity:1;transform:translate(0,0);}}
@keyframes sweep-how-box4-move{0%,70%{opacity:1;transform:translate(0,0);}76%,97%{opacity:0;transform:translate(-45px,-75px);}100%{opacity:1;transform:translate(0,0);}}
@keyframes sweep-how-box5-move{0%,76%{opacity:1;transform:translate(0,0);}82%,97%{opacity:0;transform:translate(10px,-95px);}100%{opacity:1;transform:translate(0,0);}}
@keyframes sweep-how-box6-move{0%,81%{opacity:1;transform:translate(0,0);}87%,97%{opacity:0;transform:translate(55px,-75px);}100%{opacity:1;transform:translate(0,0);}}

#sweep-how-box-1 rect{animation:sweep-how-box1-glow 11.5s ease-in-out infinite;}
#sweep-how-box-2 rect{animation:sweep-how-box2-glow 11.5s ease-in-out infinite;}
#sweep-how-box-3 rect{animation:sweep-how-box3-glow 11.5s ease-in-out infinite;}
#sweep-how-box-4 rect{animation:sweep-how-box4-glow 11.5s ease-in-out infinite;}
#sweep-how-box-5 rect{animation:sweep-how-box5-glow 11.5s ease-in-out infinite;}
#sweep-how-box-6 rect{animation:sweep-how-box6-glow 11.5s ease-in-out infinite;}
@keyframes sweep-how-box1-glow{0%,4%,100%{stroke:var(--line);fill:var(--panel);}6%{stroke:var(--info);fill:#dbe8f4;}8%{stroke:var(--line);fill:var(--panel);}}
@keyframes sweep-how-box2-glow{0%,11%,100%{stroke:var(--line);fill:var(--panel);}13%{stroke:var(--info);fill:#dbe8f4;}15%{stroke:var(--line);fill:var(--panel);}}
@keyframes sweep-how-box3-glow{0%,19%,100%{stroke:var(--line);fill:var(--panel);}21%{stroke:var(--info);fill:#dbe8f4;}23%{stroke:var(--line);fill:var(--panel);}}
@keyframes sweep-how-box4-glow{0%,26%,100%{stroke:var(--line);fill:var(--panel);}28%{stroke:var(--info);fill:#dbe8f4;}30%{stroke:var(--line);fill:var(--panel);}}
@keyframes sweep-how-box5-glow{0%,33%,100%{stroke:var(--line);fill:var(--panel);}35%{stroke:var(--info);fill:#dbe8f4;}37%{stroke:var(--line);fill:var(--panel);}}
@keyframes sweep-how-box6-glow{0%,41%,100%{stroke:var(--line);fill:var(--panel);}43%{stroke:var(--info);fill:#dbe8f4;}44%{stroke:var(--line);fill:var(--panel);}}

/* Each list row: pops up (Phase A, translateY+opacity) right after its
   box is scanned, then just sits there through the Phase B pause and
   into Phase C/D -- the row itself never moves, only its checkmark and
   its box do. Resets hidden for the next loop, masked by the scene fade. */
.sweep-how-row-1{animation:sweep-how-row1-pop 11.5s ease-out infinite;}
.sweep-how-row-2{animation:sweep-how-row2-pop 11.5s ease-out infinite;}
.sweep-how-row-3{animation:sweep-how-row3-pop 11.5s ease-out infinite;}
.sweep-how-row-4{animation:sweep-how-row4-pop 11.5s ease-out infinite;}
.sweep-how-row-5{animation:sweep-how-row5-pop 11.5s ease-out infinite;}
.sweep-how-row-6{animation:sweep-how-row6-pop 11.5s ease-out infinite;}
@keyframes sweep-how-row1-pop{0%,6%{opacity:0;transform:translate(0,8px);}10%,97%{opacity:1;transform:translate(0,0);}100%{opacity:0;transform:translate(0,8px);}}
@keyframes sweep-how-row2-pop{0%,13%{opacity:0;transform:translate(0,8px);}17%,97%{opacity:1;transform:translate(0,0);}100%{opacity:0;transform:translate(0,8px);}}
@keyframes sweep-how-row3-pop{0%,21%{opacity:0;transform:translate(0,8px);}25%,97%{opacity:1;transform:translate(0,0);}100%{opacity:0;transform:translate(0,8px);}}
@keyframes sweep-how-row4-pop{0%,28%{opacity:0;transform:translate(0,8px);}32%,97%{opacity:1;transform:translate(0,0);}100%{opacity:0;transform:translate(0,8px);}}
@keyframes sweep-how-row5-pop{0%,35%{opacity:0;transform:translate(0,8px);}39%,97%{opacity:1;transform:translate(0,0);}100%{opacity:0;transform:translate(0,8px);}}
@keyframes sweep-how-row6-pop{0%,43%{opacity:0;transform:translate(0,8px);}47%,97%{opacity:1;transform:translate(0,0);}100%{opacity:0;transform:translate(0,8px);}}

/* Each row's checkmark -- Phase C only (6.0s-10.0s of 11.5s), synced with
   that same row/box's start above (52, 58, 63, 70, 76, 81): the check
   lands, and that's what triggers its box to fly off (the box-move
   keyframes share these exact start numbers). Holds checked through the
   Phase D settle pause (97%) before resetting. */
.sweep-how-row-1 .sweep-how-rowcheck{animation:sweep-how-check1 11.5s ease-out infinite;}
.sweep-how-row-2 .sweep-how-rowcheck{animation:sweep-how-check2 11.5s ease-out infinite;}
.sweep-how-row-3 .sweep-how-rowcheck{animation:sweep-how-check3 11.5s ease-out infinite;}
.sweep-how-row-4 .sweep-how-rowcheck{animation:sweep-how-check4 11.5s ease-out infinite;}
.sweep-how-row-5 .sweep-how-rowcheck{animation:sweep-how-check5 11.5s ease-out infinite;}
.sweep-how-row-6 .sweep-how-rowcheck{animation:sweep-how-check6 11.5s ease-out infinite;}
.sweep-how-rowcheck{opacity:0;transform:scale(.4);}
@keyframes sweep-how-check1{0%,52%{opacity:0;transform:scale(.4);}56%,97%{opacity:1;transform:scale(1);}100%{opacity:0;transform:scale(.4);}}
@keyframes sweep-how-check2{0%,58%{opacity:0;transform:scale(.4);}62%,97%{opacity:1;transform:scale(1);}100%{opacity:0;transform:scale(.4);}}
@keyframes sweep-how-check3{0%,63%{opacity:0;transform:scale(.4);}67%,97%{opacity:1;transform:scale(1);}100%{opacity:0;transform:scale(.4);}}
@keyframes sweep-how-check4{0%,70%{opacity:0;transform:scale(.4);}74%,97%{opacity:1;transform:scale(1);}100%{opacity:0;transform:scale(.4);}}
@keyframes sweep-how-check5{0%,76%{opacity:0;transform:scale(.4);}80%,97%{opacity:1;transform:scale(1);}100%{opacity:0;transform:scale(.4);}}
@keyframes sweep-how-check6{0%,81%{opacity:0;transform:scale(.4);}85%,97%{opacity:1;transform:scale(1);}100%{opacity:0;transform:scale(.4);}}

.sweep-how-text{margin:0;line-height:1.5;}
@media (prefers-reduced-motion: reduce){
  .sweep-how-anim,
  .sweep-how-scanbeam,
  #sweep-how-box-1, #sweep-how-box-2, #sweep-how-box-3, #sweep-how-box-4, #sweep-how-box-5, #sweep-how-box-6,
  #sweep-how-box-1 rect, #sweep-how-box-2 rect, #sweep-how-box-3 rect, #sweep-how-box-4 rect, #sweep-how-box-5 rect, #sweep-how-box-6 rect,
  .sweep-how-row-1, .sweep-how-row-2, .sweep-how-row-3, .sweep-how-row-4, .sweep-how-row-5, .sweep-how-row-6,
  .sweep-how-rowcheck{animation:none;}
  .sweep-how-anim{opacity:1;}
  .sweep-how-scanbeam{opacity:0;}
  .sweep-how-row rect, .sweep-how-row text{opacity:1;}
  .sweep-how-rowcheck{opacity:1;transform:scale(1);}
}
