  :root{
    --wall:#8f9296; --wall-dark:#75787c; --wall-line:#7c7f83;
    --floor:#5c6570; --floor-dark:#4a525c;
    --desk:#d3c4a0; --desk-dark:#b5a67e; --desk-edge:#8c7d5c; --desk-hi:#e0d3b0;
    --monitor:#26251f; --monitor-glow:#8fe0a0; --monitor-bez:#3a3830;
    /* Bob's keyboard and mouse. A variable rather than a literal so an office
       can issue him different hardware -- see officeoptimized, which puts him
       on dark grey peripherals because cream ones vanished against its pale
       grey desk. */
    --peripheral:#d8d3c4; --peripheral-key:#b7b19c;
    --skin:#e3b98d; --shirt:#e9edf1; --tie:#8a2b2b;
    --hair:#4a3826; --ink:#201d16;
    --danger:#c23b3b; --warn:#d9a53d; --good:#5c9a5f; --gold:#e0b23a;
    --panel:#23241d; --panel-light:#33352a; --text:#f4efe0; --text-dim:#b9b39a;
    --shade: rgba(20,17,10,0.28);
    --hi: rgba(255,255,255,0.16);
  }
  *{box-sizing:border-box;}
  /* Stops a rapid double-tap on any button from being read as the browser's
     zoom gesture on mobile. No effect on desktop -- this is purely a touch
     concern, not a layout one. */
  button, .iconbtn, .tabbtn, .supportbtn{ touch-action:manipulation; }
  /* The same double-tap protection for the whole page, since a stray tap on the
     desk or the wall zoomed the game just as readily as one on a button.
     Draggables keep their own touch-action:none and are unaffected. */
  html{ touch-action:manipulation; -webkit-text-size-adjust:100%; }
  html,body{height:100%;}
  body{
    margin:0; min-height:100%; background:#111008;
    background-image: radial-gradient(ellipse at 50% 0%, #1c1a12 0%, #0d0c08 70%);
    display:flex; align-items:center; justify-content:center;
    font-family:'VT323','Courier New',monospace; color:var(--text); padding:16px;
  }
  button{ font-family:inherit; }
  /* Visible keyboard focus. Nothing in the game showed one before this --
     several spots below explicitly stripped it -- so a keyboard player had no
     way to see where they were on the page. */
  :focus-visible{ outline:3px solid var(--gold); outline-offset:2px; }

  .cabinet{
    /* Grows to fill more of the browser viewport on large screens instead of
       sitting in a small fixed box, capped by whichever of these binds first:
       an absolute ceiling (1000px), 92% of viewport width, or a height-derived
       ceiling that keeps .cabinet's total height within 88% of viewport height.
       .cabinet's height = .stage's height (3/4 of .cabinet's inner width, via
       aspect-ratio) + ~193px of titlebar/status-row/coffee-button/padding/
       border chrome above and below the stage (measured directly, not a
       theoretical estimate) + 32px of body padding. On a short window this
       naturally settles back near the original 640px rather than overflowing.
       dev.html overrides this below to also clear its fixed dev bar. */
    /* The height-derived term above is what keeps a tall game inside a short
       window, and on a desktop that is exactly right. On a phone held sideways
       it is a disaster: 390px of height puts this at about 186px wide, a
       column narrower than the coffee button. It therefore only applies where
       there is real height to work with, and never shrinks below 320px. Below
       that height the width is driven by the viewport alone and the page is
       allowed to scroll (see the short-screen rule further down). */
    width:100%; max-width:min(1000px, 92vw); background:var(--panel);
    border:4px solid var(--ink); box-shadow:0 0 0 2px #000, 8px 8px 0 rgba(0,0,0,0.5);
    padding:10px; position:relative;
    /* The ruler everything inside is measured against. The office is drawn in
       percentages of this box and always has been, so it stretches with the
       window; the pop-ups on top of it were drawn in fixed pixels and did not,
       which meant a card covering 70% of the office in one window covered 44%
       of it in another. Naming the cabinet a container lets those pop-ups be
       sized in cqw -- 1cqw is 1% of this width -- so what a card covers stays
       constant and a placement decided once holds at every size.
       inline-size, not size: the cabinet's width comes from the rule above,
       never from its contents, but its HEIGHT does come from its contents and
       full size containment would collapse it.
       The same technique is already shipping one layer down -- see
       .desktopScreen, which has been scaling SYNERGY's icons this way for
       weeks. */
    container-type:inline-size; container-name:cabinet;
    /* The unit every pop-up is measured in. 2.217cqw is 17px in the owner's own
       ~864px-wide window -- the size all of this was laid out against -- so
       nothing moves for him; bigger windows scale up from it and smaller ones
       down, which is the whole point of the exercise.

       A custom property rather than nested em on purpose. em compounds: a 12px
       label inside a 13px chip inside a 17px panel needs three divisions to get
       right and breaks the moment anything is re-nested. This inherits
       unchanged, so every rule states its own ratio against one shared base and
       none of them can interfere with each other.

       Calibrated against the cabinet's CONTENT box, since container units
       exclude its border and padding -- 28px of the 795 it measures on screen.

       The floor is not decoration. Pure proportion puts body copy at 7px on a
       small phone, and shrinking below readable was never the point: on a small
       screen the answer is layout, not smaller type. */
    --ui: clamp(13px, 2.217cqw, 26px);
  }
  @media (min-height: 600px){
    .cabinet{ max-width:min(1000px, 92vw, max(320px, calc((88vh - 225px)/0.75 + 28px))); }
  }
  /* A short window -- a phone held sideways, mostly -- gets a game bigger than
     it can show at once, so it has to be scrollable rather than clipped and
     vertically centred on nothing. */
  @media (max-height: 599px){
    html, body{ height:auto; }
    body{ align-items:flex-start; padding:8px; }
  }
  .titlebar{ display:flex; align-items:center; justify-content:space-between; padding:2px 6px 10px 6px; }
  /* A slow press on anything draggable otherwise pops iOS's text-selection
     magnifier and a copy/paste bubble over the game. */
  .decorItem, .toyItem, .papers, .clockwrap, .desktopIcon, .iconbtn, .stage, .solBoard,
  .monitor, .closedMonitorHit, .titlebar h1, .startTitle{
    -webkit-user-select:none; user-select:none; -webkit-touch-callout:none;
  }
  .titlebar h1{ font-family:'Press Start 2P',monospace; font-size:13px; margin:0; color:var(--gold); letter-spacing:1px; text-shadow:2px 2px 0 var(--ink); }
  .titlebar .btns{ display:flex; gap:6px; position:relative; }
  /* Held open from the sound button. Width is the span the panel is meant to
     cover -- the stats button's left edge to the sound button's right edge --
     which at 32px buttons and 6px gaps is three buttons plus two gaps. Right
     aligned to .btns so it stays put if buttons are ever added on the left.
     z-index because .stage is positioned and comes later in the document, so
     without it the scene paints over the panel; this is HUD chrome sitting
     above the stage, not part of the scene's own paint order. */
  /* Hidden with opacity+visibility rather than display, so it can fade rather
     than blink out when it times out or is dismissed. The delayed visibility
     transition is what keeps it hit-testable and painted for the length of the
     fade; on the way in, .show overrides that so it appears promptly. */
  .volumePanel{
    position:absolute; top:calc(100% + 5px); right:0; z-index:30;
    width:108px; display:flex; flex-direction:column; gap:5px;
    background:var(--panel-light); border:2px solid var(--ink);
    box-shadow:2px 2px 0 var(--ink); padding:6px 7px;
    opacity:0; visibility:hidden; pointer-events:none;
    transition:opacity 350ms ease, visibility 0s linear 350ms;
  }
  .volumePanel.show{
    opacity:1; visibility:visible; pointer-events:auto;
    transition:opacity 120ms ease;
  }
  .volRow{ display:flex; align-items:center; gap:5px; cursor:pointer; }
  .volIcon{ font-size:11px; line-height:1; color:var(--text); width:10px; text-align:center; flex:none; }
  .volumePanel input[type=range]{
    -webkit-appearance:none; appearance:none; flex:1; min-width:0;
    height:4px; background:var(--ink); outline:none; cursor:pointer;
  }
  .volumePanel input[type=range]:focus-visible{ outline:2px solid var(--gold); outline-offset:3px; }
  .volumePanel input[type=range]::-webkit-slider-thumb{
    -webkit-appearance:none; appearance:none; width:8px; height:14px;
    background:var(--gold); border:1.5px solid var(--ink); cursor:pointer;
  }
  .volumePanel input[type=range]::-moz-range-thumb{
    width:8px; height:14px; border-radius:0;
    background:var(--gold); border:1.5px solid var(--ink); cursor:pointer;
  }
  .iconbtn{ background:var(--panel-light); border:2px solid var(--ink); color:var(--text); width:32px; height:32px; font-size:16px; cursor:pointer; display:flex; align-items:center; justify-content:center; box-shadow:2px 2px 0 var(--ink); }
  .iconbtn svg{ flex-shrink:0; }
  #terminalBtn{ font-family:'Space Mono',monospace; font-weight:700; font-size:14px; color:#8fef6a; }
  #supportBtn{ font-size:20px; }
  .iconbtn:active{ transform:translate(2px,2px); box-shadow:none; }

  /* ---- phones ----------------------------------------------------------
     430px, not the 620px Solitaire uses, because this is the width where the
     top bar genuinely runs out of room: "CAFFEINATED BOB" at 13px plus six
     32px buttons and their gaps needs about 429px, so below that the buttons
     used to hang off the right-hand edge -- the computer, Support and sound were
     entirely unreachable on an iPhone SE, and a Pixel 7 cleared it by nothing
     at all. Worse, the overflow made the whole PAGE scroll sideways, so a
     thumb-swipe meant for the office dragged the game off-centre and any
     panel opened afterwards came up half off-screen.

     No desktop window is ever this narrow, so none of this reaches one. */
  @media (max-width: 430px){
    .titlebar{ flex-wrap:wrap; justify-content:center; row-gap:8px; }
    .titlebar h1{ width:100%; text-align:center; }
    .titlebar .btns{ justify-content:center; }
    /* 44px is the size Apple and Google both publish for a thumb. Solitaire's
       controls were raised to it already; the top bar, the panel close buttons
       and the Marketplace tabs were left behind at 32. The wrapped bar above
       is what makes the room for it. */
    .iconbtn{ width:44px; height:44px; font-size:20px; }
    #supportBtn{ font-size:24px; }
    #terminalBtn{ font-size:18px; }
    /* Still "three buttons plus two gaps", at the size those buttons now are. */
    .volumePanel{ width:144px; }
  }

  .stage{ position:relative; width:100%; aspect-ratio:4/3; background:var(--wall); border:3px solid var(--ink); overflow:hidden; }
  .ceiling{ position:absolute; top:0; left:0; right:0; height:16%; background:#c2c4c1; background-image:repeating-linear-gradient(90deg, transparent 0 78px, rgba(0,0,0,0.08) 78px 80px), repeating-linear-gradient(0deg, transparent 0 28px, rgba(0,0,0,0.06) 28px 30px); border-bottom:3px solid #8c8f8c; }
  .ceilingLight{ position:absolute; top:0; width:3%; height:15%; background:#f4f3ea; border:1.5px solid #a8a89c; }
  .wallpanel{ position:absolute; top:16%; bottom:42%; left:0; right:0; background:repeating-linear-gradient(90deg, var(--wall) 0 58px, var(--wall-line) 58px 60px); }
  .wallSide{ position:absolute; top:16%; bottom:42%; width:9%; background:#65686c; }
  .wallSide.left{ left:0; }
  .wallSide.right{ right:0; }
  .neighborGlimpse{ position:absolute; top:16%; bottom:42%; width:3%; background:#54575b; opacity:0.75; }
  .neighborGlimpse.left{ left:0; }
  .neighborGlimpse.right{ right:0; }
  .neighborDesk{ position:absolute; bottom:42%; width:2.5%; height:5%; background:#7a6b4a; opacity:0.55; }
  .wallCorner{ position:absolute; top:16%; bottom:42%; width:4px; background:#45474a; }
  .wallTrim{ position:absolute; left:0; right:0; top:16%; height:2.5%; background:#b0b3b6; border-bottom:3px solid #7c7f83; }
  .wallSeam{ position:absolute; top:18.5%; bottom:42%; width:3px; background:var(--wall-line); }
  .baseboard{ position:absolute; left:0; right:0; bottom:42%; height:3%; background:var(--wall-dark); }
  .fluorflicker{ position:absolute; inset:0 0 42% 0; background:rgba(238,243,223,0.05); animation:flicker 7s infinite steps(1); pointer-events:none; }
  @keyframes flicker{ 0%,100%{opacity:1;} 92%{opacity:1;} 93%{opacity:.4;} 94%{opacity:1;} 96%{opacity:.55;} 97%{opacity:1;} }
  .floor{ position:absolute; left:0; right:0; bottom:0; height:42%; background:repeating-linear-gradient(0deg, var(--floor) 0 10px, var(--floor-dark) 10px 11px); }

  /* ---- office skins ----
     The room shell -- ceiling, cubicle wall, floor -- as ONE background image
     per office, instead of the dozen absolutely-positioned divs above.
     Deliberately a background rather than markup: a background cannot be
     clicked, cannot be measured, and cannot land in the middle of the paint
     order, so an office skin is incapable of disturbing anything in the room.
     Everything that IS interactive -- the desk, Bob, the monitor, the clock,
     the three placement zones -- is untouched and sits on top exactly where it
     always did.
     Each skin is drawn to the SAME three bands the divs above use: ceiling
     0-16%, cubicle wall 16-58%, floor the bottom 42%. That is what makes these
     skins and not a re-layout: every stored item position is a fraction of its
     zone, so the zones must not move, so the art has to come to the zones.
     preserveAspectRatio="none" in each file plus background-size:100% 100%
     means the art stretches to the stage box, which is locked to 4/3 anyway.
     The desk's three colours ride along with the skin, since a desk is part of
     the office it stands in. */
  .stage[data-office]{ background-size:100% 100%; background-repeat:no-repeat; background-position:center; }
  .stage[data-office] > .ceiling,
  .stage[data-office] > .ceilingLight,
  .stage[data-office] > .wallpanel,
  .stage[data-office] > .wallSide,
  .stage[data-office] > .neighborGlimpse,
  .stage[data-office] > .neighborDesk,
  .stage[data-office] > .wallCorner,
  .stage[data-office] > .wallSeam,
  .stage[data-office] > .wallTrim,
  .stage[data-office] > .baseboard,
  .stage[data-office] > .floor{ display:none; }
  /* .fluorflicker is NOT hidden: it is a translucent flicker over the whole
     wall, and it reads correctly over any of the three skins. */
  .stage[data-office="officedefault"]{ background-image:url("/play/skins/office-default.svg"); --desk:#d3c4a0; --desk-dark:#b5a67e; --desk-edge:#8c7d5c; }
  /* Neutral greys, no blue in them: the first set were bluish and read as a
     cold cast on the one large flat surface in the room. */
  .stage[data-office="officeoptimized"]{ background-image:url("/play/skins/office-optimized.svg"); --desk:#d4d3cf; --desk-dark:#b4b3ae; --desk-edge:#85847e;
    /* Dark grey hardware. The cream keyboard and mouse the other rooms use
       sat on a pale grey desk here and all but disappeared into it -- the
       owner's note, and correct: this is the one office whose desk is close
       enough in value to the default peripherals to swallow them. */
    --peripheral:#4a4a48; --peripheral-key:#2e2e2c; }
  .stage[data-office="officerefined"]{ background-image:url("/play/skins/office-refined.svg"); --desk:#f3f2ec; --desk-dark:#dcdbd3; --desk-edge:#a5a49c; }
  /* Beige Standard Issue: oatmeal fabric with an aluminium trim rail, yellowed
     tiles and a brown carpet that has seen things. Deliberately the most
     nineteen-nineties room in the game, which is what stops it reading as a
     recolour of the grey one. */
  .stage[data-office="officefunctional"]{ background-image:url("/play/skins/office-functional.svg"); --desk:#c9b68e; --desk-dark:#a99672; --desk-edge:#7f6e4f; }

  /* 10% smaller than it was (12.5% -> 11.25% -> 10.125%): the clock was
     eating more of the wall than it earns, and the wall zone below is
     smaller now. The numerals do NOT shrink with it -- buildClockNumerals
     scales its font up by the same 10% so they stay exactly the size they
     were on screen, which is the readable limit. Change one and change the
     other. */
  .clockwrap{ position:absolute; top:25.5%; left:50%; width:10.125%; aspect-ratio:1/1; cursor:grab; touch-action:none; transform:translate(-50%,-50%); }
  .clockwrap svg{ width:100%; height:100%; display:block; }

  /* Sized so that wall decor ends up with exactly the clock's travel area,
     which is the reference for anything hung on the cubicle wall.
     makeDraggable keeps an item wholly inside its zone, so a decor item's
     centre is clamped to the zone inset by half the item's own size -- these
     four values are the solution to that inset landing on the clock's own
     bounds (x 0.0968-0.9032, y 0.209-0.521 of the stage, see the clockEl
     makeFreeDraggable call). Every wall item is .decorItem at a uniform 11% of
     the zone width (.bigItem/33% is only the plant, which lives on the floor in
     .deskDecorZone), so one rect is exact for all of them, and .stage is locked
     to aspect-ratio 4/3, so the vertical solution holds at any viewport size.
     Changing the zone, the item width, or the stage aspect breaks the match --
     re-solve rather than nudging these by eye. */
  /* Also re-measured by the owner (dev picker, "Play area"): left 10.6, top
     15.5, w 79.2, h 41.1. Smaller than it was, because the cubicle wall in the
     new offices is a narrower panel between two angled sides -- the old box let
     wall decor hang past the panel and onto the side walls. The clock is
     clamped to this same box (decorZoneClamp), so it follows automatically. */
  .decorZone{ position:absolute; top:15.5%; left:10.6%; width:79.2%; height:41.1%; pointer-events:none; }
  .decorItem{ position:absolute; width:11%; aspect-ratio:1/1; cursor:grab; touch-action:none; user-select:none; pointer-events:auto; }
  /* The floor: where the base of a free-standing item may be put down. Matches
     the visible floor art -- .floor runs 0%-42% from the bottom and .baseboard
     marks the wall join at 42% -- so the top of this band is exactly where the
     floor stops, and nothing can be stood up the wall. Only the front 1% is
     held back -- practically the whole floor is usable, right down to the frame
     edge. Stopping any higher leaves a visible strip of floor that nothing can
     be stood on, and at 18% it put the entire front of the room -- everything
     nearer than the desk's front panel at 19.5% -- out of reach.
     This bounds the item's BASE only; its artwork is free to rise above the
     band, which is what lets a tall plant stand against the cubicle wall.
     Height is 42% - 1% = 41%.
     Was 1%-41% from the bottom and 98% wide, which made the box for the 33%-wide
     plant TALLER than the zone containing it -- the clamp below then resolved to
     Math.max(0.539, Math.min(0.461, y)), pinning the plant to one horizontal rail
     it could never leave, with its pot clipped off the bottom of the stage. */
  /* Re-measured by the owner against the new office art (dev picker, "Play
     area" frame): left -0.1, right 99.8, top 58.4, bottom 100.4. This is only
     the BOUNDING BOX -- the floor itself is a hexagon, because the two angled
     cubicle walls cut a wedge out of each back corner. The box is what item
     positions are fractions of; FLOOR_SHAPE in game.js is the shape they are
     actually held inside. NOTE: item positions are
     stored as fractions of THIS box, so changing it shifts anything already
     placed -- see the note in loadCareer. */
  .floorZone{ position:absolute; left:-0.1%; right:0.2%; bottom:-0.4%; height:42%; pointer-events:none; }
  /* The plant, and only the plant.

     Its box is square and a third of the floor zone wide; the plant drawn in it
     fills the middle 58% horizontally and 78% vertically (measured with
     getBBox). The whole box answered the pointer, so the plant could be picked
     up from a hand's width away from any leaf -- the owner: "the grab area is
     way bigger than the actual item, especially horizontally and a little bit
     vertically as well."

     The box itself cannot shrink: every plant anybody has placed is stored as a
     position within it, and resizing would move them all. So the box stays and
     the CLIP changes. clip-path is hit-tested as well as painted, so this
     trims the dead space away from the pointer without moving anything -- and,
     unlike taking the element out of hit testing, it leaves the pointer capture
     that dragging depends on completely alone.

     The insets are the art's own bounds on the 24-unit icon grid, with about
     half a unit of margin: left 4.5/24, right (24-19.3)/24, top 2.4/24,
     bottom (24-22)/24. Nothing is clipped that is ever drawn. */
  .decorItem.bigItem{ width:33%; clip-path: inset(10% 19.6% 8.3% 18.75%); }
  /* Smaller than the plant, larger than a picture frame: a banker's box read as
     an object standing in the room. Its aspect and width come from the cropped
     viewBox in ITEM_VIEWBOXES, so the element hugs the artwork instead of
     carrying a square of empty floor around with it -- that padding was grab
     area, and it made the plant hard to put down anywhere nearby. Rendered size
     is unchanged; only the invisible margin is gone. */
  .decorItem.floorBox{ width:12.3%; aspect-ratio:15.4/14.1; }
  /* Hung 5% of the office lower than they were. At top:0 the whole string sat up
     inside the ceiling band, which in the new offices reads as lights buried in
     the tiles rather than strung up. 10% was tried first and dropped them too
     far down the wall; this is the half-way house the owner asked for. */
  .stringLightsFull{ position:absolute; top:5%; left:0; width:100%; height:10%; pointer-events:none; }
  .stringLightsFull svg{ width:100%; height:100%; overflow:visible; }
  .decorItem:active{ cursor:grabbing; }
  .decorItem svg{ width:100%; height:100%; overflow:visible; }

  .desk{ position:absolute; left:16%; right:16%; bottom:8%; height:32.5%; background:var(--desk-dark); border-top:4px solid var(--desk-edge); }
  .deskFront{ position:absolute; left:16%; right:16%; bottom:19.2%; height:8.5%; background:var(--desk-dark); }
  .deskFront::before{ content:""; position:absolute; left:12%; width:3px; top:15%; bottom:15%; background:var(--desk-dark); opacity:.4; }
  .deskFront::after{ content:""; position:absolute; right:12%; width:3px; top:15%; bottom:15%; background:var(--desk-dark); opacity:.4; }
  /* The desk's surface band, repainted in front of Bob. .desk sits behind him
     and so cannot occlude anything; this covers the identical span (top 59.5%
     to 72.3%, matching .desk's top edge and .deskFront's top edge) but paints
     AFTER .charwrap in the scene markup, so it is what hides his lower half.
     Bob is sized and placed against these two edges -- hands on the top, feet
     on the bottom -- so his lower half is exactly this band's height and the
     surface swallows it whole when he sits down. Keep it flush with both edges.
     This is the only part of the desk in the light var(--desk); .desk itself,
     .deskFront and the legs are all var(--desk-dark), so the body reads as one
     colour and the surface is the single lighter plane sitting on top of it. */
  .deskSurface{ position:absolute; left:16%; right:16%; bottom:27.7%; height:12.8%; background:var(--desk); }

  .keyboard{ position:absolute; left:42.9%; bottom:35.5%; width:17%; height:4%; background:var(--peripheral); border:2px solid var(--ink); }
  .keyboard::after{ content:""; position:absolute; inset:15% 8%; background:repeating-linear-gradient(90deg, var(--peripheral-key) 0 3px, transparent 3px 5px); }
  /* Flipped end-for-end: the mouse is symmetric left-to-right, so swapping the
     top and bottom corner radii is the same 180-degree rotation without a
     transform (which would spawn a stacking context for no reason). Order is
     top-left, top-right, bottom-right, bottom-left. */
  .mouse{ position:absolute; left:38.9%; bottom:35.5%; width:2.6%; height:4%; background:var(--peripheral); border:2px solid var(--ink); border-radius:20% 20% 40% 40%; }
  /* This declaration IS the paper's standard spawn: resetPaperStackPosition
     clears the saved position and inline styles each new day so the stack falls
     back to exactly here, which is why the coordinates live in one place rather
     than being duplicated as a JS constant. Positioned so the stapler's own
     spawn lands on the LEFT side of the pile -- move one and the other needs
     re-measuring against it. */
  .papers{ position:absolute; left:23.18%; bottom:31.79%; width:11%; height:3%; cursor:grab; touch-action:none; }
  /* Ten stapleable sheets in the footprint the three static ones used to
     occupy. The offsets are percentages, not the concept's pixels: the whole
     stack is only ~15px tall in game, so 2.67% per sheet is what keeps the top
     sheet (24% + 72% height) inside the element. Removing sheets shortens the
     stack by ~24% overall, which reads as the pile going down even though any
     single sheet is a fraction of a pixel. */
  /* Each sheet's height on the pile, tilt and paper tone are set inline by
     renderPaperStack, derived from PAPER_SHEETS_PER_STACK. They used to be a
     hardcoded ladder of :nth-child rules, which only covered ten sheets and
     would have silently left everything above that unpositioned the moment the
     stack got deeper. The tilt arrives as a custom property rather than an
     inline transform specifically so .removing can still override the
     transform -- an inline transform would outrank the class and kill the
     stapling animation. */
  .paperSheet{
    position:absolute; left:0; right:0; height:72%; overflow:hidden;
    background:#efe9d6; border:1.5px solid var(--ink);
    transform-origin:50% 80%;
    transform:rotate(var(--tilt,0deg));
    transition:opacity 90ms ease-out, transform 110ms ease-out;
  }
  .paperSheet.removing{ opacity:0; transform:translateY(12%) scale(.97) rotate(0deg); }
  .papers.gone{ display:none; }
  /* A pile that was dragged off the delivery spot and left unfinished. Same
     look and same drag as the pile at the spot; only its position differs, and
     that is written on the element by renderPaperExtras. */
  .papersExtra{ left:auto; }
  /* Document artwork stretches to the sheet with preserveAspectRatio="none",
     so it is drawn in a 100x55 box with deliberately heavy strokes -- a sheet
     is about 76x11 real pixels, so anything under ~4 units of stroke weight
     disappears entirely at this scale. */
  .paperArt{ position:absolute; inset:0; width:100%; height:100%; display:block; pointer-events:none; }

  .monitor{ position:absolute; left:59%; bottom:41%; width:21.25%; height:19.55%; background:var(--monitor-bez); border:3px solid var(--ink); cursor:pointer; transform:perspective(340px) rotateY(-14deg); transform-style:preserve-3d; }
  .monitor:hover{ filter:brightness(1.08); }
  /* REFINED gets the terminal from the severed floor: a cream box with the
     screen sunk into a thick rounded bezel and a cool blue-white glow in it,
     rather than the dark flat panel every other room issues. Only the box --
     the real ones have the keyboard built in, and Bob's keyboard and mouse
     stay where they are, so nothing about how the desk works changes.
     Scoped to the office rather than swapped in markup, so owning the room is
     the whole of what turns it on. */
  .stage[data-office="officerefined"] .monitor{
    background:#e6e1d3; border-color:#2a2721;
    border-radius:7% 7% 5% 5% / 9% 9% 7% 7%;
    box-shadow: inset 0 -6px 0 rgba(0,0,0,0.10), inset 0 2px 0 rgba(255,255,255,0.45);
  }
  /* Sunk deeper at the bottom than the top, which is what gives the bezel its
     chin and reads as a machine from before flat panels. */
  .stage[data-office="officerefined"] .monitor .screen{
    inset:12% 12% 22% 12%; background:#08161f; border-radius:4px;
    box-shadow: inset 0 0 10px rgba(130,205,225,0.30), inset 0 0 2px rgba(255,255,255,0.15);
  }
  /* The vent slot on the chin. */
  .stage[data-office="officerefined"] .monitor::after{
    content:''; position:absolute; left:50%; bottom:7%; width:16%; height:5%;
    transform:translateX(-50%); background:#2a2721; border-radius:2px; opacity:0.55;
  }
  .stage[data-office="officerefined"] .monitor .stand,
  .stage[data-office="officerefined"] .monitor .foot{ background:#cfc9b8; border:2px solid #2a2721; }
  .monitor .screen{ position:absolute; inset:14%; background:var(--monitor); overflow:hidden; }
  .monitor .screen svg{ width:100%; height:100%; }
  /* The monitor's base. A child of .monitor, so it picks up the same
     perspective/rotateY skew and stays put if the monitor ever moves. Sized by
     what actually renders rather than by the raw numbers: the perspective
     transform means the layout box and the painted box differ, and most of the
     element sits hidden behind the monitor body. bottom:-14%/height:14% puts
     1.89 points of it below the monitor at 5.12 wide -- the ~2%x5% asked for --
     with its bottom edge at 61.6%, resting on the desk surface (top 59.5%).
     .monitor paints after .deskSurface in the scene markup, without which the
     surface buries the base. */
  .monitor .stand{ position:absolute; left:50%; bottom:-14%; width:18.3%; height:14%; background:var(--ink); transform:translateX(-50%); }
  /* The foot the stand rests on, twice the stand's width and ~1.5 points tall.
     Also a child of .monitor, so it inherits the same skew. Like the stand,
     these numbers are chosen by what renders, not by the arithmetic: at
     width:36.8%/height:5.7% it paints 7.25 wide by 1.53 tall, against the
     stand's 3.6. It overlaps the stand's bottom edge slightly so the two
     read as one piece, and its own bottom lands at 62.3%, on the surface. */
  .monitor .foot{ position:absolute; left:50%; bottom:-20%; width:36.8%; height:5.7%; background:var(--ink); transform:translateX(-50%); }
  .mailBadge{ position:absolute; top:-9px; right:-9px; background:var(--danger); color:#fff; border:2px solid var(--ink); border-radius:50%; width:20px; height:20px; font-size:12px; display:flex; align-items:center; justify-content:center; font-family:'Space Mono',monospace; font-weight:700; }

  /* Matches .deskSurface exactly, so a desk toy can only ever be placed on the
     desk's surface -- the same way .decorZone bounds wall decor to the cubicle
     wall. Keep these four values in sync with .deskSurface. */
  .toyZone{ position:absolute; left:16%; right:16%; bottom:27.7%; height:12.8%; pointer-events:none; }
  .toyItem{ position:absolute; width:9%; aspect-ratio:1/1; cursor:grab; touch-action:none; user-select:none; pointer-events:auto; }
  /* The Pen Holder is the one desk toy issued narrower than the standard 9%.
     It ships equipped on every new career and has to share the strip of desk
     left of the keyboard with the paper stack and the stapler, and at 9% there
     is no gap on that strip wide enough to take it (3.27% left of the papers,
     1.47% between the stapler and the mouse, against a 6.07%-of-stage box).
     4.8% fits the left gap exactly, and a narrow pen cup is the right shape for
     the object anyway -- the static pen holder element this replaced was
     hand-sized at 4.5% of the stage for the same reason. Since raised from that
     original 4.8% by a quarter, so it reads at desk scale rather than as a
     thimble; it no longer fits the gap left of the paper stack, which is fine
     now that it isn't placed on the desk by default. */
  /* aspect-ratio matches the icon's 20x28 viewBox, so the cup FILLS its box
     instead of letterboxing inside the default square one. That is what makes
     it render 20% wider and 40% taller than the old 20x24-in-a-square version
     at the same 4.8% width -- the extra width was already there as dead space. */
  .toyItem[data-item-id="penholder"]{ width:6%; aspect-ratio:20/28; }

  /* Red Stapler. The top presses in and the base lifts a fraction, so the thing
     compresses like a real stapler without its resting silhouette moving at all.
     transform-box:view-box means these px values are viewBox user units -- 24ths
     of the icon -- which is why sub-pixel numbers are the right magnitude here. */
  .staplerTop, .staplerBase{ transform-box:view-box; }
  .toyItem.clacking .staplerTop{ animation:staplerTopClack 150ms cubic-bezier(.35,0,.25,1); }
  .toyItem.clacking .staplerBase{ animation:staplerBaseClack 150ms cubic-bezier(.35,0,.25,1); }
  @keyframes staplerTopClack{
    0%{ transform:translateY(0); } 28%{ transform:translateY(.78px); }
    48%{ transform:translateY(.78px); } 75%{ transform:translateY(-.10px); }
    100%{ transform:translateY(0); }
  }
  @keyframes staplerBaseClack{
    0%{ transform:translateY(0); } 28%{ transform:translateY(-.18px); }
    48%{ transform:translateY(-.18px); } 75%{ transform:translateY(.04px); }
    100%{ transform:translateY(0); }
  }

  /* Rubber Duck. The body takes most of the squeeze; the head compresses less
     and settles toward the body, then both overshoot on release. fill-box so
     each group scales about its own centre rather than the icon's origin. */
  .duckBody, .duckHead{ transform-box:fill-box; transform-origin:center; }
  .toyItem.squeezing .duckBody{ transform:scaleX(.90) scaleY(.82); }
  .toyItem.squeezing .duckHead{ transform:translate(-.25px,1.05px) scaleX(.96) scaleY(.92); }
  .toyItem.releasing .duckBody{ animation:duckBodyRelease .34s cubic-bezier(.2,.8,.25,1); }
  .toyItem.releasing .duckHead{ animation:duckHeadRelease .34s cubic-bezier(.2,.8,.25,1); }
  @keyframes duckBodyRelease{
    0%{ transform:scaleX(.90) scaleY(.82); } 48%{ transform:scaleX(1.035) scaleY(1.055); }
    72%{ transform:scaleX(.985) scaleY(.98); } 100%{ transform:scaleX(1) scaleY(1); }
  }
  @keyframes duckHeadRelease{
    0%{ transform:translate(-.25px,1.05px) scaleX(.96) scaleY(.92); }
    48%{ transform:translate(.1px,-.25px) scaleX(1.015) scaleY(1.025); }
    72%{ transform:translate(0,.08px) scaleX(.995) scaleY(.995); }
    100%{ transform:translate(0,0) scaleX(1) scaleY(1); }
  }

  /* Mini Zen Garden. The rake sweeps left-to-right across the tray; the jostle
     is applied to the inner <svg>, NOT the .toyItem wrapper, because the
     wrapper carries the translate(-50%,-50%) centring that syncMultiSlot sets
     inline -- animating the wrapper would fight it and teleport the tray. */
  .sandLine{ fill:none; stroke:#8d7348; stroke-width:.72; stroke-linecap:round; stroke-linejoin:round; }
  .zenStone{ transition:transform .25s ease; transform-box:fill-box; transform-origin:center; }
  .zenRake{ transform-origin:8px 7px; transform-box:view-box; }
  .zenRake.raking{ animation:zenRakeSweep .52s cubic-bezier(.35,.1,.25,1); }
  @keyframes zenRakeSweep{
    0%{ transform:translateX(0) rotate(0); } 16%{ transform:translateX(-1.2px) rotate(-7deg); }
    72%{ transform:translateX(8.5px) rotate(5deg); } 100%{ transform:translateX(0) rotate(0); }
  }
  .zenJostle{ animation:zenJostle .24s ease-out; }
  @keyframes zenJostle{
    0%{ transform:rotate(0); } 22%{ transform:rotate(-1.5deg); }
    48%{ transform:rotate(1.2deg); } 75%{ transform:rotate(-.5deg); } 100%{ transform:rotate(0); }
  }

  /* Pen Holder. will-change keeps the per-frame rattle off the main raster
     path; the rest of each pen's transform is written inline by makePenHolder. */
  .pen{ transform-box:view-box; will-change:transform; }

  /* String Lights. A failing bulb is not a glow or an opacity blink -- it is the
     flat fill stepping to a darker variant and back, which is the only thing
     that reads as "broken" in this art style. steps(1,end) keeps every change
     an instant cut rather than a fade. */
  .stringLightBulb{ pointer-events:auto; cursor:pointer; }
  .bulbCore{ fill:var(--bulb-color); }
  .stringLightBulb.bad .bulbCore{ animation:badBulbFlicker 1.35s steps(1,end) infinite; }
  @keyframes badBulbFlicker{
    0%{ fill:var(--bulb-color); }  8%{ fill:var(--bulb-dark); } 15%{ fill:var(--bulb-color); }
    23%{ fill:var(--bulb-mid); }  29%{ fill:var(--bulb-color); } 51%{ fill:var(--bulb-color); }
    56%{ fill:var(--bulb-dark); } 62%{ fill:var(--bulb-color); } 68%{ fill:var(--bulb-mid); }
    74%{ fill:var(--bulb-color); } 100%{ fill:var(--bulb-color); }
  }
  /* One tiny voltage wobble when a bulb is clicked back to life, then solid. */
  .stringLightBulb.fixedFlash .bulbCore{ animation:fixedBulbSettle 280ms steps(1,end) 1; }
  @keyframes fixedBulbSettle{
    0%{ fill:var(--bulb-dark); } 28%{ fill:var(--bulb-color); } 46%{ fill:var(--bulb-mid); }
    64%{ fill:var(--bulb-color); } 100%{ fill:var(--bulb-color); }
  }
  .toyItem:active{ cursor:grabbing; }
  .toyItem svg{ width:100%; height:100%; overflow:visible; }
  /* Fidget Cube solve/scramble feedback (makeFidgetCubeGame) -- applied to
     the svg itself, not the .toyItem wrapper, since the wrapper's own
     transform:translate(-50%,-50%) is what keeps it centered on its
     left/top position and an animation here would fight that. */
  @keyframes cubeSolveKick{ 0%{ transform:rotate(0deg) scale(1); } 45%{ transform:rotate(-2deg) scale(1.03); } 100%{ transform:rotate(0deg) scale(1); } }
  @keyframes cubeScrambleKick{ 0%{ transform:rotate(0deg); } 20%{ transform:rotate(-6deg); } 45%{ transform:rotate(5deg); } 70%{ transform:rotate(-3deg); } 100%{ transform:rotate(0deg); } }
  .cubeSolveKick{ animation:cubeSolveKick .18s ease-out; }
  .cubeScrambleKick{ animation:cubeScrambleKick .34s ease-out; }

  /* Bob's mug while he is holding it but not mid-sip -- NOT a desk object.
     It is placed against the straight-arm left hand (the same hand that grips
     #pCupInHand during a sip), measured at 46.2%/57.36% with Bob at the desk,
     so it has to move whenever .charwrap does. Parked anywhere else it reads
     as a stray mug sitting on the desk. Note the mug is not centred on the
     hand: within its 20-wide viewBox the body is x2-16 and the handle x16-20,
     so `left` is offset to bring the HANDLE onto the hand and hang the body
     clear to its left, rather than covering the hand with the mug's body. */
  .cup{ position:absolute; width:4%; height:7%; transition:left .5s cubic-bezier(.4,0,.3,1), bottom .5s cubic-bezier(.4,0,.3,1), transform .5s ease; left:42.2%; bottom:39.2%; display:none; }
  .cup.show{ display:block; }
  .cup svg{ width:100%; height:100%; overflow:visible; }

  /* left matches the walk-in start in applyPose(); every seated pose overrides
     it inline, so this only shows for the frame before the first pose applies. */
  .charwrap{ position:absolute; bottom:26.92%; left:-2%; width:26%; pointer-events:none; }
  .chairEl{ position:absolute; bottom:37.92%; left:39.375%; width:11%; pointer-events:none; }
  .chairEl svg{ width:100%; display:block; overflow:visible; }
  .deskLeg{ position:absolute; bottom:0; width:2.4%; background:var(--desk-dark); border:2px solid var(--desk-edge); }
  .charwrap svg{ width:100%; overflow:visible; display:block; }
  .charwrap.hidden{ visibility:hidden; }
  /* Bob is inert except his head, which toggles the headphones. A descendant can
     opt back in even though .charwrap is pointer-events:none, so this is the only
     clickable part of him. .decorZone paints before .charwrap, so his head is
     already drawn in front of wall decor and now takes the click there too.
     visibleFill, not all: the *visible* values respect visibility, so the hit
     target goes dead with .charwrap.hidden (the 'offscreen' pose) instead of
     leaving a clickable ghost of Bob's head behind. The Fill part means the
     transparent interior is still a target, which a paint-based value would
     refuse. */
  .charwrap #pHeadHit{ pointer-events:visibleFill; cursor:pointer; }
  /* The tap lifts the arm rather than pressing it down. Bob's hands rest at
     59.35% and .deskSurface starts at 59.39% and paints in front of him, so
     there is only 0.04 of headroom below them -- pressing down 3px (0.87 stage
     points) buried 0.83 of each hand behind the desk on every keystroke. Going
     up keeps the whole hand visible and still reads as typing, since it returns
     to rest on the keys. Do not flip these back to a positive translateY
     without first giving the hands room to travel into. */
  @keyframes typeTapA{ 0%{ transform:translateY(0); } 35%{ transform:translateY(-3px); } 100%{ transform:translateY(0); } }
  @keyframes typeTapB{ 0%{ transform:translateY(0); } 35%{ transform:translateY(-3px); } 100%{ transform:translateY(0); } }
  #pArmR.typingBurstA, #pArmL.typingBurstA{ animation:typeTapA 0.22s ease-out; }
  #pArmR.typingBurstB, #pArmL.typingBurstB{ animation:typeTapB 0.22s ease-out; }
  @keyframes spin{ to{ transform:rotate(360deg); } }
  .swirl{ animation:spin 0.9s linear infinite; }

  /* Sleeping z's: each one rises off Bob's head, drifts right, grows a little
     and fades. All three share one path and one duration; the stagger below is
     what turns it into a stream instead of a pulse. transform-box:fill-box so
     the scale grows from each glyph's own centre rather than the SVG origin --
     without it they slide away toward the corner as they grow. */
  @keyframes sleepZFloat{
    0%   { opacity:0; transform:translate(0,0) scale(.55); }
    18%  { opacity:1; }
    70%  { opacity:.85; }
    100% { opacity:0; transform:translate(15px,-27px) scale(1.3); }
  }
  /* backwards fill matters: without it the two delayed z's sit fully opaque and
     motionless on top of the first one until their delay elapses, so the effect
     opens with three stacked z's that suddenly jump into motion. */
  .sleepZ{ animation:sleepZFloat 2.7s linear infinite; animation-fill-mode:backwards; transform-box:fill-box; transform-origin:50% 50%; }
  .sleepZ1{ animation-delay:0.9s; }
  .sleepZ2{ animation-delay:1.8s; }

  .bubble{ position:absolute; background:var(--panel); color:var(--text); border:2px solid var(--ink); padding:3px 7px; font-size:13px; white-space:nowrap; opacity:0; pointer-events:none; z-index:5; }
  .bubble.play{ animation:bubblefloat 3.2s ease-out forwards; }
  @keyframes bubblefloat{ 0%{opacity:0; transform:translateY(0);} 12%{opacity:1; transform:translateY(-4px);} 80%{opacity:1; transform:translateY(-14px);} 100%{opacity:0; transform:translateY(-20px);} }

  .closedOverlay{ position:absolute; inset:0; background:rgba(10,10,8,0.72); display:none; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:10%; z-index:6;}
  /* Bob's computer stays clickable after hours -- the easter egg is that the
     dark, empty office still answers with a SYNERGY login. Done with an
     invisible hit target laid over the monitor rather than by lifting the
     monitor above the scrim: raising it made it read as a lit panel floating
     over the dark scene, and it covered the EMPTY CUBICLE text. Geometry is
     copied from .monitor, which shares this coordinate space (both are
     absolute against .stage). Sits below BOTH scrim buttons, which claim their
     own stacking context: the hit target reaches left to 59%, well inside a
     centred button, so anything down here that does not claim a z-index has
     its right-hand end swallowed by the monitor. renderClosedScrim goes
     further and hides this target entirely whenever AFTER HOURS AUTHORIZATION
     is being offered -- see the note there. */
  .closedMonitorHit{ position:absolute; left:59%; bottom:41%; width:21.25%; height:19.55%; background:none; border:0; padding:0; cursor:pointer; }
  #closedTitleMenuBtn, #afterHoursBtn, #clockInBtn{ position:relative; z-index:2; }
  .closedOverlay.show{ display:flex; }
  .closedOverlay .big{ font-size:22px; color:var(--text); margin-bottom:8px; }
  .closedOverlay .small{ font-size:16px; color:var(--text-dim); }
  /* Sized to sit with the EMPTY CUBICLE line rather than shout over it -- this
     is a way out of a dead screen, not a call to action competing with it. */
  #closedTitleMenuBtn, #afterHoursBtn, #clockInBtn{ margin-top:20px; font-size:12px; padding:10px 14px; }
  /* When both are up they read as one pair, not two separate offers. Both
     of these are adjacent-sibling rules, so they hold whether or not the
     button above is currently displayed -- which is what lets CLOCK IN and
     AFTER HOURS AUTHORIZATION share the one slot above the way out. */
  #afterHoursBtn + #closedTitleMenuBtn{ margin-top:10px; }
  #clockInBtn + #afterHoursBtn{ margin-top:10px; }

  .hud{ padding:10px 4px 2px 4px; }
  .energyrow{ display:flex; align-items:center; gap:8px; margin-bottom:8px; }
  .barwrap{ flex:1; height:22px; background:var(--panel-light); border:2px solid var(--ink); position:relative; overflow:visible; }
  .barfill{ position:absolute; left:0; top:0; bottom:0; width:0%; background:var(--good); }
  .barfill.zone-red-low{ background:var(--danger); }
  .barfill.zone-yellow-low{ background:#d9c23a; }
  .barfill.zone-green{ background:var(--good); }
  .barfill.zone-yellow-high{ background:#d9c23a; }
  .barfill.zone-red-high{ background:var(--danger); animation:pulse 1s infinite; }
  .barfill.zone-candy{ background-image:repeating-linear-gradient(45deg, #c23b3b 0 8px, #f4f1e8 8px 16px); animation:pulse .4s infinite; }
  @keyframes pulse{ 0%,100%{filter:brightness(1);} 50%{filter:brightness(1.35);} }
  .barmark100{ position:absolute; top:-4px; bottom:-4px; width:2px; background:var(--ink); left:66.6%; }
  /* Dev builds only -- the class is added by enableEnergyBarScrubbing(), which
     never runs outside dev, so the live game's bar keeps the default cursor. */
  .barwrap.devScrub{ cursor:ew-resize; }
  /* Two stacked lines now (energy, then the live earning rate), so it needs a
     wider column than the old bare "25%" and no longer sits on the bar's
     centre line -- align to the top so the energy figure stays level with the
     bar it belongs to. */
  .barlabel{ font-size:16px; min-width:112px; text-align:right; align-self:flex-start; line-height:1.15; }
  .barlabelLine{ white-space:nowrap; }
  /* The rate carries its own warning. Red means Bob is earning literally
     nothing right now -- asleep, crashed out, off the clock, or past the daily
     cap -- which is the one state a player needs to notice without reading.
     Green is the band worth aiming for. Everything in between stays plain, so
     the colour only ever means something. */
  /* No font-size of its own: it inherits .barlabel's 16px so the two lines
     match. It used to be 14px, which read as a footnote to the energy
     percentage rather than the other half of the same readout -- and the
     earn rate is the number the whole game turns on. */
  .barlabelRate{ color:var(--text); }
  .barlabelRate.rateZero{ color:var(--danger); }
  .barlabelRate.rateHigh{ color:var(--good); }
  .statusrow{ font-size:16px; color:var(--text-dim); margin-bottom:10px; min-height:20px; }
  .coffeebtn{ display:block; width:100%; background:var(--panel-light); border:3px solid var(--ink); color:var(--text); font-family:'Press Start 2P',monospace; font-size:12px; padding:14px 10px; cursor:pointer; box-shadow:4px 4px 0 var(--ink); letter-spacing:1px; }
  .coffeebtn:not(:disabled):hover{ background:#3d3f31; }
  .coffeebtn:not(:disabled):active{ transform:translate(4px,4px); box-shadow:none; }
  .coffeebtn:disabled{ opacity:.5; cursor:default; }

  .startscreen{ position:absolute; inset:0; background:var(--panel); display:flex; flex-direction:column; align-items:center; gap:18px; text-align:center; padding:20px; z-index:20; overflow-y:auto; scrollbar-width:none; }
  .startscreen::-webkit-scrollbar{ display:none; }
  /* max-width so a fixed-width child cannot make this panel wider than the
     screen. It is a shrink-to-fit flex item, so without a cap here its own
     width came from the 348px button stack inside it -- which made the
     stack's max-width:100% resolve to 348px and clamp nothing at all. */
  .startscreen .startscreenPanel{ margin:auto 0; position:relative; z-index:10; max-width:100%; }
  .startBg{ position:absolute; inset:0; overflow:hidden; z-index:0; }
  .startBgTrack{ position:absolute; inset:0; display:flex; will-change:transform; }
  .startBgTile{ position:relative; width:800px; height:100%; flex:none; }
  .startBgTile svg{ width:100%; height:100%; display:block; }
  #startBgClouds{ animation:startBgScroll 200s linear infinite; z-index:0; }
  #startBgSkyline{ animation:startBgScroll 130s linear infinite; z-index:1; }
  #startBgFg{ animation:startBgScroll 60s linear infinite; z-index:2; }
  @keyframes startBgScroll{ from{ transform:translateX(0); } to{ transform:translateX(-800px); } }
  .startBgDark{ position:absolute; inset:0; background:rgba(10,10,8,0.62); z-index:3; pointer-events:none; }
  .soundStartBtn{ position:absolute; right:14px; bottom:14px; z-index:11; }
  .startscreen h1{ font-family:'Press Start 2P',monospace; font-size:22px; color:var(--gold); text-shadow:3px 3px 0 var(--ink); margin:0; }
  .betaTag{ font-size:12px; color:var(--text-dim); text-shadow:none; vertical-align:middle; }
  .startscreen p{ color:var(--text-dim); font-size:17px; max-width:460px; margin:0 0 16px; }
  .startbtn{ background:var(--good); border:3px solid var(--ink); color:var(--ink); font-family:'Press Start 2P',monospace; font-size:13px; padding:14px 22px; cursor:pointer; box-shadow:4px 4px 0 var(--ink); }
  /* max-width so the stack cannot grow wider than the screen it is in: the
     buttons' own max-width:100% resolves against THIS, and without it a
     348px stack simply hung off both sides of a 320px phone. */
  .startMenuBtns{ display:flex; flex-direction:column; align-items:center; gap:12px; max-width:100%; }
  /* One width for every label in both menus, so the two read as the same menu
     with one section swapped rather than as two different screens. 348px is the
     measured natural width of the longest label anywhere in either -- APPLICANT
     ONBOARDING, at 310px -- plus roughly 19px of air either side, the same air
     the menu has always had. Rename a button to anything longer and this needs
     re-measuring rather than guessing.
     The font scales down on a narrow screen because 310px of label does not fit
     a 320px phone at any padding. clamp holds it at 13px on anything desktop-
     sized, so the priority platform never sees the smaller type. */
  .startMenuBtns .startbtn{ width:327px; max-width:100%; font-size:clamp(10px, 2.4vw, 13px); }
  .startbtn:active{ transform:translate(4px,4px); box-shadow:none; }
  .devnote{ font-size:13px; color:var(--warn); border:1px dashed var(--warn); padding:6px 10px; max-width:380px; }
  /* Padded clear of the sound button, which floats over this at right:14px and
     a higher z-index. Without it the last line ran underneath and the word
     "Policy" was clipped off the Terms link on a phone -- a legal link, half
     visible. Symmetric so the centring stays true. */
  /* In the flow, as the last item in the start screen's flex column -- NOT
     absolutely positioned, which is what it used to be. Absolute took it out of
     the layout entirely, so the menu had no idea it was there: adding a fourth
     button was enough to grow the stack straight down through the footer on a
     phone. In flow, the panel's `margin:auto 0` centres it in whatever is left
     above the footer, and the two can never occupy the same pixels.
     The horizontal padding keeps the text clear of the sound button, which
     floats over the bottom-right corner at a higher z-index; without it the
     last line ran underneath and the Terms link was clipped mid-word. */
  .startFooter{ position:relative; z-index:10; align-self:stretch; text-align:center; font-size:12px; color:var(--text-dim); line-height:1.6; padding:0 56px; }
  /* Each phrase wraps as a whole or not at all. Two lines on any real desktop,
     but a phone is narrow enough to break one -- and left to itself it broke
     mid-phrase, stranding "LLC." alone on one line and "Development" on
     another. Wrapping at the joins instead keeps every line a complete thought
     however many of them there are. */
  .startFooter .fchunk{ white-space:nowrap; }
  a, a:visited{ color:#4da6ff; }
  a:hover{ color:#7cc0ff; }
  /* The two long-read panels get ~10% more width, so the Terms and the Handbook
     wrap over fewer lines. Only these two: the rest are short forms and menus
     that read fine as they are. Both still yield to the .overlay/.startscreen
     padding on narrow screens, so this only widens where there is room.
     The Terms panel needs BOTH rules, and the paragraph one is the rule that
     actually does the work. The panel shrinks to fit its contents, so its width
     is set by the widest thing in it -- the paragraphs -- and .panelBox's cap
     never binds at all. 557 plus 16px of body padding and a 3px border each
     side renders at 595, about 10% up from the 544 it sat at. The cap is raised
     with it only so it stays out of the way; it is headroom, not a width.
     The Handbook keeps its own 572, since only the Terms panel was asked for. */
  #tosGateContent .panelBox{ max-width:calc(var(--ui) * 37.0554); }
  #readmeOverlay .panelBox{ max-width:calc(var(--ui) * 33.6438); }
  #tosGateContent .panelBody p{ max-width:calc(var(--ui) * 32.7616); }
  /* The Terms gate is the one long-read panel that lives on the START SCREEN
     rather than inside an .overlay, and that difference is what broke its
     scrolling. .panelBox caps its own height at 92%, which needs a containing
     block with a REAL height to be 92% OF. An .overlay has one, because it is
     inset:0. This panel's container did not -- its height came from its own
     contents, so the cap resolved to 92% of however tall the Terms happened to
     be, which constrains nothing. The panel grew past the bottom of the screen,
     and .startscreen's own overflow:auto began scrolling the title screen
     underneath it: two scrollbars at once, the outer one dragging the street
     and the sky up past a panel that was supposed to be still.
     Giving the container the screen's own height puts that cap back on real
     ground. The panel now fits, .panelBody does the only scrolling there is,
     and the start screen never has anything to scroll. min-height:0 because a
     flex item refuses to shrink below its content without it, which would put
     the whole problem straight back. */
  #tosGateContent{ height:100%; min-height:0; display:flex; align-items:center; justify-content:center; }
  /* The Employee File stacks two things that used to be separate panels, so the
     file itself needs a rule between it and whatever sits above -- the sign-in
     form on the title screen, the way out in-game -- or the two read as one
     run-on block. :not(:first-child) keeps it off when the stats are the only
     section showing. */
  /* Toggled from renderEmployeePanel rather than keyed off :not(:first-child):
     the sections above this one are hidden by `display:none` in most states, and
     a CSS sibling selector counts DOM position, not visibility -- so the rule
     drew a divider under nothing on the Applicant's panel. */
  #empStatsBody.hasSectionAbove,
  #empAccountSection.hasSectionAbove{ margin-top:14px; padding-top:14px; border-top:2px solid var(--ink); }
  /* The file's header: who this record belongs to, before anything about how
     they are performing. Reads as two stamped fields on a personnel form. */
  /* RETURN TO TITLE MENU has no rules of its own any more: it is the third
     child of .empActions and takes that stack's layout and 8px gap like the two
     above it. It used to be position:sticky at the bottom of the scrolling file
     so it was always on screen -- and that is exactly what made it sit on top of
     the LEADERBOARD button as soon as the file scrolled. */
  /* Was itself the flex row of two stamped fields. It is now the block that
     holds whichever version of the identity is on show -- the two fields, or
     the engraved plate that replaces them -- so the row moved inside it. */
  .empIdentity{ margin-bottom:14px; }
  .empIdentity:empty{ display:none; }
  .empIdRow{ display:flex; gap:10px; }
  .empIdField{ flex:1; background:var(--panel-light); border:2px solid var(--ink); padding:8px 10px; }
  /* The nameplate itself: one brushed plate with two screws, carrying the name
     large and the badge number and start date beneath it. It REPLACES the two
     fields rather than joining them, which is the owner's design and the reason
     claiming one is worth doing -- the file gets tidier, not busier.
     The second line wraps rather than shrinking, so a long start date on a
     narrow phone takes two lines instead of overflowing the plate. */
  .empPlate{ position:relative; text-align:center; padding:11px 18px 9px;
    background:linear-gradient(180deg,#3f382a 0%,#2b261c 100%); border:2px solid var(--ink);
    box-shadow:inset 0 1px 0 rgba(255,255,255,0.10), 3px 3px 0 rgba(0,0,0,0.35); }
  .empPlateName{ font-family:'Press Start 2P',monospace; color:var(--gold); letter-spacing:.10em;
    font-size:max(11px, calc(var(--ui) * 0.94)); text-shadow:0 1px 0 rgba(0,0,0,0.6);
    overflow-wrap:anywhere; }
  .empPlateSub{ font-family:'Space Mono',monospace; color:var(--text-dim); margin-top:5px;
    letter-spacing:.06em; font-size:max(8px, calc(var(--ui) * 0.62)); }
  .empPlateScrew{ position:absolute; left:8px; top:50%; transform:translateY(-50%);
    width:6px; height:6px; border-radius:50%; background:#191512;
    box-shadow:inset 0 1px 1px rgba(255,255,255,0.25); }
  .empPlateScrewR{ left:auto; right:8px; }
  /* The empty state, under the two fields: an unengraved blank waiting to be
     filled in, which is what a dashed border reads as on a form. */
  /* Under the plate, and deliberately quieter than the invitation it replaces:
     claiming a nameplate is something to advertise, changing one is not. */
  .empPlateChange{ text-align:right; margin-top:5px; }
  .empPlateChange a{ font-family:'Space Mono',monospace; color:var(--text-dim);
    font-size:max(8px, calc(var(--ui) * 0.58)); letter-spacing:.06em; }
  .empPlateChange a:hover{ color:var(--gold); }
  .empPlateInvite{ margin-top:10px; border:2px dashed var(--text-dim); padding:10px 12px; text-align:center; }
  .empPlateInviteLine{ font-family:'Space Mono',monospace; color:var(--text-dim);
    font-size:max(9px, calc(var(--ui) * 0.7)); }
  .empPlateInvite a{ display:inline-block; margin-top:4px; font-family:'Space Mono',monospace;
    color:var(--gold); font-size:max(8px, calc(var(--ui) * 0.62)); letter-spacing:.06em; }
  .empIdLbl{ font-size:max(8px, calc(var(--ui) * 0.7059)); letter-spacing:.08em; text-transform:uppercase; color:var(--text-dim); }
  .empIdVal{ font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 1.0)); font-weight:700; color:var(--gold); margin-top:2px; }
  /* Form NPC-1. Shaped like the After Hours authorization it sits beside: a
     masthead, a form number, a wall of small print, and a stamp at the end. */
  .npcFormMeta{ display:flex; justify-content:space-between; gap:12px;
    font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 0.6));
    color:var(--text-dim); letter-spacing:.06em; margin-bottom:10px; }
  .npcFine{ font-size:max(8px, calc(var(--ui) * 0.62)); color:var(--text-dim);
    line-height:1.5; margin-top:10px; }
  /* The approval, stamped rather than announced. Tilted a little, the way a
     stamp lands, and in the game's green rather than its gold so it reads as a
     decision made about the form rather than another heading on it. */
  .npcStamp{ display:inline-block; font-family:'Press Start 2P',monospace;
    font-size:max(10px, calc(var(--ui) * 0.85)); color:var(--good);
    border:3px solid var(--good); padding:8px 14px; margin:4px 0 12px;
    transform:rotate(-6deg); opacity:.9; letter-spacing:.08em; }
  #tosGateContent .panelBox{ text-align:left; }
  /* Side by side, and both of them real buttons. The strength of a click-to-
     agree gate rests on the refusal being as available as the acceptance, so
     DECLINE is not grey micro-text under the green one -- it is the same size
     and the same shape, in a quieter colour. Wraps to two rows rather than
     shrinking on a narrow phone. */
  .tosGateBtns{ display:flex; gap:10px; justify-content:center; margin:16px 0 0; }
  /* Share the row evenly and shrink rather than wrap, so the two stay side by
     side all the way down to a small phone. */
  .tosGateBtns .startbtn{ flex:1 1 0; min-width:0; max-width:190px; padding:14px 8px;
    white-space:nowrap; font-size:clamp(10px, 2.6vw, 13px); }
  .tosDecline{ background:var(--panel-light); color:var(--text); }
  .scrollIndicatorTrack{ position:absolute; top:12px; bottom:12px; right:6px; width:6px; border-radius:3px; background:rgba(0,0,0,0.25); z-index:25; display:none; }
  .scrollIndicatorTrack.show{ display:block; }
  .scrollIndicatorThumb{ position:absolute; left:0; right:0; border-radius:3px; background:var(--text-dim); opacity:0.6; }

  .overlay{ position:absolute; inset:0; background:rgba(8,8,6,0.85); display:none; align-items:center; justify-content:center; z-index:30; padding:16px; }
  .overlay.show{ display:flex; }
  /* max-width in the same unit as the type inside it: a panel that grew its
     words but not its box would just wrap more. width:100% still caps it to
     whatever room the overlay has, which is what keeps phones as they were. */
  .panelBox{ font-size:var(--ui); position:relative; background:var(--panel); border:3px solid var(--ink); width:100%; max-width:calc(var(--ui) * 30.5853); max-height:92%; display:flex; flex-direction:column; box-shadow:6px 6px 0 rgba(0,0,0,0.5); }
  /* The one-device question. Narrower than .panelBox's 520 because it is a
     choice, not a document -- and it has to come AFTER that rule to win, since
     the two have the same specificity and it was silently doing nothing above
     it. Its two answers sit side by side, with the one that changes nothing on
     the right. */
  /* After Hours Authorization. Shaped like the TPS report on purpose -- a
     masthead, a reference number, a wall of policy, and boxes to tick. */
  .ahBox{ max-width:calc(var(--ui) * 32.9381); }
  .ahPaper{ background:#f4f1e8; color:#201d16; padding:14px 16px; font-size:max(8px, calc(var(--ui) * 0.8824)); line-height:1.5; }
  .ahPaper p{ margin:0 0 10px; }
  .ahPaper .sectionhead{ color:#6b5526; margin:14px 0 6px; }
  .ahMast{ display:flex; justify-content:space-between; gap:12px; border-bottom:2px solid #201d16; padding-bottom:6px; margin-bottom:10px; font-size:max(8px, calc(var(--ui) * 0.7059)); }
  .ahRow{ display:flex; gap:10px; align-items:baseline; margin-bottom:8px; }
  .ahRow label{ font-size:max(8px, calc(var(--ui) * 0.6471)); letter-spacing:.06em; color:#6b5526; min-width:120px; }
  .ahRow .formfield{ width:90px; margin:0; }
  .ahAck{ display:flex; gap:8px; align-items:flex-start; margin-bottom:8px; font-size:max(8px, calc(var(--ui) * 0.7647)); cursor:pointer; }
  .ahAck input{ margin-top:3px; flex:none; }
  .ahBtns{ display:flex; justify-content:center; margin-top:14px; }
  .ahProblems{ color:#8a2b2b; font-size:max(8px, calc(var(--ui) * 0.7647)); margin:8px 0; }
  .ahProblems ul{ margin:4px 0 0; padding-left:18px; }
  .ahNote{ background:#fdf6e8; border-left:3px solid #d9a53d; padding:6px 9px; font-size:max(8px, calc(var(--ui) * 0.7647)); }
  .ahDenied{ color:#8a2b2b; font-weight:700; }
  .ahGranted{ color:#3f6b3f; font-weight:700; font-size:max(8px, calc(var(--ui) * 1.0)); letter-spacing:.05em; }
  .sessionBox{ max-width:calc(var(--ui) * 25.2917); }
  .sessionBox .panelBody p{ margin:0 0 12px; }
  .sessionBtns{ display:flex; gap:10px; justify-content:center; flex-wrap:wrap; margin-top:16px; }
  /* Narrower than .sessionBox because it is read standing up on a phone, and
     a short line is the whole point of the panel. The buttons stack rather
     than sit side by side: RETURN TO MENU is a real choice, not a cancel, and
     two 20-character labels squeezed onto one phone-width row read as one
     button that has broken in half. */
  /* The one-off explainer cards. Same furniture as the device advisory above --
     a headed panel with one button -- because they do the same job: a short
     thing said once, read, and dismissed. Slightly wider, because these carry
     two paragraphs and the advisory carries three short ones. */
  .discoveryBox{ max-width:calc(var(--ui) * 26); }
  .discoveryBox .panelBody p{ margin:0 0 12px; }
  .discoveryBox .panelBody p:last-of-type{ margin-bottom:16px; }
  /* The line that only an Applicant sees: what this particular work does not
     pay them, and the offer to change that. Set apart rather than buried in the
     paragraph above it, so the rule reads as a rule rather than as a caveat. */
  .discoveryApplicant{ color:var(--text-dim); font-style:italic; }
  .mobileNoticeBox{ max-width:calc(var(--ui) * 24); }
  .mobileNoticeBox .panelBody p{ margin:0 0 12px; }
  .mobileNoticeBox .sessionBtns{ flex-direction:column; }
  .mobileNoticeJargon{ color:var(--text-dim); font-style:italic; }
  /* CONTINUE ON MOBILE is eighteen characters of a monospace pixel face, which
     at the standard 13px measures 234px. The narrowest phone still sold -- a
     320px iPhone SE -- leaves 194px inside the button, so it broke across two
     lines next to a one-line RETURN TO MENU and the pair read as one button
     that had come apart. Measured on that device: 10px is 180px and the last
     size that fits. Flat rather than scaled, because the panel is a fixed
     reading width and every wider phone gets the same generous margin. The
     14px vertical padding is untouched, so the tap target stays full size. */
  .mobileNoticeBox .startbtn, .mobileNoticeBox .supportbtn{ font-size:10px; padding:14px 8px; }

  /* ---- calculator (a desktop program) ----
     Narrower than the other panels on purpose: a calculator that stretches to
     a 520px dialog stops reading as a calculator. */
  .calcBox{ max-width:calc(var(--ui) * 18.9394); }
  .calcBody{ padding:14px; }
  /* Tabular figures and a fixed height so the answer does not jitter sideways
     as it is typed, and the window does not resize when the digits run long.
     Right-aligned, like every calculator ever made. */
  .calcDisplay{
    display:block; width:100%; box-sizing:border-box;
    background:#20261f; border:2px solid var(--ink); color:#8fe08f;
    font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 1.5294)); font-weight:700;
    font-variant-numeric:tabular-nums; letter-spacing:1px;
    text-align:right; padding:12px 12px; margin-bottom:12px;
    min-height:52px; line-height:28px;
    overflow:hidden; text-overflow:clip; white-space:nowrap; direction:ltr;
  }
  .calcPad{ display:grid; grid-template-columns:repeat(4, 1fr); gap:8px; }
  .calcKey{
    font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 1.0588)); font-weight:700;
    background:var(--panel-light); border:2px solid var(--ink); color:var(--text);
    padding:14px 0; cursor:pointer; user-select:none;
  }
  .calcKey:hover{ background:#4a4c3c; }
  .calcKey:active{ transform:translate(1px,1px); }
  .calcKey:focus-visible{ outline:2px solid var(--gold); outline-offset:2px; }
  /* The three across the top are the ones that throw work away or reinterpret
     it, so they read as lighter and cooler than the digits. */
  .calcFn{ background:#5a5c4c; }
  .calcFn:hover{ background:#6a6c58; }
  /* Operators carry the same gold the rest of the game uses for "this is the
     action", which also makes the column scannable at a glance. */
  .calcOp{ background:var(--gold); border-color:var(--ink); color:#241f10; }
  .calcOp:hover{ background:#f0cf63; }
  /* Zero spans two columns, as it does on the calculator this is copying. */
  .calcZero{ grid-column:span 2; }
  .panelClose{ position:absolute; top:-14px; right:-14px; z-index:5; }
  /* The terminal's full-screen toggle, tucked just inside the close button.
     Backslash is the one panel whose content is genuinely limited by the
     panel: a fixed-height scrollback that SYNERGY fills faster than anything
     else in the game, on a box sized for a settings dialog. */
  .panelMaximize{ position:absolute; top:-14px; right:24px; z-index:5; }
  /* The Marketplace keeps the standard panel width. It was widened by 10% a
     side for the office cards and that was the wrong lever: it changed the
     whole shop's column count to make room for one new category. The offices
     scroll sideways in their own row instead (see .storeGrid.officeRow), which
     is what a growing range actually needs. The tab row only has to be wide
     enough for the six categories, and at this width it is. */
  /* flex-shrink:0 is load-bearing, not tidiness.

     A panel is a flex column capped at 92% of the overlay, and when its
     contents do not fit, the browser shrinks whichever items it is allowed to.
     A heading or a balance bar protects itself: a flex item's automatic minimum
     size is its content, so there is nothing to take. A tab row with
     overflow-x:auto on it does NOT -- any overflow other than visible drops that
     automatic minimum to zero -- so the row became the one thing in the panel
     that could be squashed, and the fuller the shelf underneath it, the harder
     it was squashed.

     Measured on an iPhone 12 with the Marketplace open: 24px of tab row on
     OFFICES, which fits; 19px on DESK and MUGS; 15px on OUTFITS, the category
     with the most items. The row was being cropped in proportion to how much
     was on sale under it, which is exactly how the owner described finding it.

     The shelf is the part that is supposed to absorb it -- it scrolls, and it
     is the only thing here that should. */
  .tabs{ display:flex; border-bottom:3px solid var(--ink); flex-shrink:0; }
  .tabsScrollWrap{ flex-shrink:0; }
  /* Same reasoning, one level up: the two bars above the tabs are never the
     thing to give way either. Stated rather than left to the automatic minimum,
     which is a rule about content and would stop protecting them the moment
     either one gained an overflow of its own. */
  .supportHead, .cvBalanceBar{ flex-shrink:0; }
  /* Six categories filling one row, and never wrapping: a second line of tabs
     would push the shelf down, and the onboarding card that sits under the
     first row of items is measured off the shelf.
     flex:1 0 auto rather than the plain flex:1 above -- grow to share out the
     spare width so the row is continuous edge to edge, but never shrink below
     the label, so OUTFITS and OFFICES cannot be clipped. On a window too narrow
     for six the row scrolls sideways instead. The scrollbar is hidden: a
     visible track would add height to the row, which is the thing being
     avoided. */
  #customizeTabs{ flex-wrap:nowrap; overflow-x:auto; scrollbar-width:none; }
  #customizeTabs::-webkit-scrollbar{ display:none; }
  #customizeTabs .tabbtn{ flex:1 0 auto; white-space:nowrap; }
  .tabbtn{ flex:1; background:var(--panel-light); border:none; color:var(--text-dim); font-family:'Press Start 2P',monospace; font-size:max(8px, calc(var(--ui) * 0.5882)); padding:12px 4px; cursor:pointer; }
  .tabbtn.active{ background:var(--panel); color:var(--gold); }

  /* ---- the leaderboard ----
     Ten categories in ONE row, scrolling sideways. They were wrapped onto three
     lines first and the owner did not like it: a block of stacked tabs reads as
     a menu of its own rather than as the top of the thing underneath it, and
     the eye has to hunt for which one is on.

     So: no wrapping, no stretching, each tab as wide as its label, and the row
     scrolls. leaderboardSelect() brings the active one into view, since a tab
     marked active off the end of the row is worse than no marking at all.
     A visible slim track is deliberate -- it is the only thing telling somebody
     with a mouse that there is more to the right. */
  .tabs.tabsScroll{ flex-wrap:nowrap; overflow-x:auto; overflow-y:hidden; scrollbar-width:thin; scrollbar-color:var(--text-dim) transparent; }
  .tabs.tabsScroll .tabbtn{ flex:0 0 auto; white-space:nowrap; padding-left:14px; padding-right:14px; }
  .tabs.tabsScroll::-webkit-scrollbar{ height:6px; }
  .tabs.tabsScroll::-webkit-scrollbar-thumb{ background:var(--text-dim); }
  .tabs.tabsScroll::-webkit-scrollbar-track{ background:transparent; }
  /* The fade at whichever end still has tabs behind it, toggled by
     leaderboardSyncTabOverflow() from a real measurement -- so a window wide
     enough for all ten shows neither.
     It hangs off a WRAPPER rather than off the scrolling row itself: a
     pseudo-element inside a horizontal scroller is a flex item like any other,
     so it scrolls away with the tabs instead of staying at the edge. The first
     attempt did exactly that and drew nothing at all.
     pointer-events:none so a fade never eats a click meant for the tab under it. */
  .tabsScrollWrap{ position:relative; }
  .tabsScrollWrap::before, .tabsScrollWrap::after{ content:''; position:absolute; top:0; bottom:0; width:30px; pointer-events:none; opacity:0; transition:opacity .15s; z-index:2; }
  .tabsScrollWrap::before{ left:0; background:linear-gradient(to right, var(--panel-light) 30%, rgba(35,36,29,0)); }
  .tabsScrollWrap::after{ right:0; background:linear-gradient(to left, var(--panel-light) 30%, rgba(35,36,29,0)); }
  .tabsScrollWrap.moreLeft::before{ opacity:1; }
  .tabsScrollWrap.moreRight::after{ opacity:1; }
  /* An ordinary panel, and deliberately so. It filled the whole office for an
     afternoon -- the owner's idea, then his second thought: "overkill". So it
     is the same box every other panel in this game is, sized in --ui like the
     rest of them, which is what makes it work on a phone without a phone rule
     of its own. A little wider than the default, because this one holds a
     table with as many as four columns and the others hold prose.

     .panelBox already supplies width:100%, max-height:92% and the column
     layout; only the width ceiling and the scrolling body are ours. */
  #leaderboardPanel{ max-width:calc(var(--ui) * 34); }
  /* overflow-x too: the TPS board carries four columns, and on a narrow window
     that is the one table here wide enough to need somewhere to go. */
  #leaderboardPanel .panelBody{ flex:1; min-height:0; overflow-y:auto; overflow-x:auto; }
  .lbHead{ font-family:'Press Start 2P',monospace; font-size:max(9px, calc(var(--ui) * 0.7059)); color:var(--gold); margin-bottom:4px; }
  .lbNote{ font-size:max(9px, calc(var(--ui) * 0.6471)); color:var(--text-dim); margin-bottom:12px; }
  .lbTable{ width:100%; border-collapse:collapse; font-family:'Space Mono',monospace; font-size:max(10px, calc(var(--ui) * 0.7647)); }
  .lbTable th{ text-align:left; color:var(--text-dim); font-weight:400; font-size:max(9px, calc(var(--ui) * 0.6471)); padding:0 8px 6px 0; border-bottom:2px solid var(--ink); }
  .lbTable td{ padding:5px 8px 5px 0; border-bottom:1px solid rgba(32,29,22,0.35); color:var(--text); }
  .lbTable .lbRank{ width:3.5em; color:var(--text-dim); }
  /* A row with a nameplate on it: the chosen name first, the badge number under
     it in small print. The number is never dropped -- identity on this board is
     ultimately the badge, whatever a name is made to look like.
     break-word rather than nowrap: sixteen characters is longer than a badge
     number, and on a phone the column has to give way by wrapping rather than
     by pushing the score off the side of the panel. */
  .lbTable .lbWho{ overflow-wrap:anywhere; }
  .lbTable .lbNameRow{ display:flex; align-items:baseline; gap:0; }
  .lbTable .lbName{ font-weight:700; min-width:0; overflow-wrap:anywhere; }
  /* Quiet until wanted. A report flag that competes with the name beside it
     invites presses that were never about the name. */
  .lbFlag{ background:none; border:none; padding:0 0 0 6px; cursor:pointer;
    color:var(--text-dim); opacity:.4; font-size:max(9px, calc(var(--ui) * 0.62)); line-height:1; }
  .lbFlag:hover{ opacity:1; color:var(--danger); }
  .lbFlag:disabled{ opacity:.7; color:var(--good); cursor:default; }
  .lbTable .lbBadgeSub{ font-size:max(8px, calc(var(--ui) * 0.55)); color:var(--text-dim); }
  .lbTable .lbVal{ text-align:right; padding-right:0; white-space:nowrap; }
  /* The Overall board is twelve columns wide and is meant to be pushed
     sideways -- the owner asked for the slider. Its cells get a little breathing
     room back and the average placing, which is the column the board is sorted
     by, is set off from the nine it is made of. */
  .lbTable .lbVal + .lbVal{ padding-left:14px; }
  /* Left aligned rather than right, at the owner's request -- twelve narrow
     numbers stacked ragged-right read as a wall the eye has to hunt across;
     lined up on the left they read as a row instead. The gap between columns
     moves from the left of each number (needed for a right-aligned one) to
     the right of it, so it doesn't collapse against the shorter values. */
  /* The Overall board carries ten value columns beside the Employee one, so the
     browser had been giving that column whatever was left over -- about six
     characters, which turned "The Bobfather" into four stacked lines. Sixteen
     characters is the longest a nameplate can be, and 9.5em of monospace holds
     it on one line at every size the panel is drawn at; the numbers beside it
     are short and give up the width without complaint. */
  /* On the Overall board the name does not wrap. Ten value columns sit beside
     the Employee one, so the browser had been giving it whatever was left --
     about six characters, which turned "The Bobfather" into four stacked lines.
     min-width alone could not fix that: .lbWho carries overflow-wrap:anywhere so
     a long name gives way on a phone, and the browser will happily break a word
     rather than honour a minimum. So this board says NOT to break it, and the
     panel it sits in already scrolls sideways if the whole table outgrows it.
     The narrow boards keep their wrapping -- there the name has room, and a
     phone would rather wrap than scroll. */
  .lbTable.lbOverall .lbWho{ overflow-wrap:normal; }
  .lbTable.lbOverall .lbName{ white-space:nowrap; overflow-wrap:normal; }
  .lbTable.lbOverall .lbVal{ text-align:left; padding-right:14px; }
  .lbTable.lbOverall .lbVal + .lbVal{ padding-left:0; }
  .lbTable .lbAvg{ color:var(--gold); border-left:2px solid var(--ink); padding-left:14px; }
  .lbTable tr.lbMine td.lbAvg{ font-weight:700; }
  /* The reader's own row. Gold and bold, which is the same pair the game uses
     for anything that belongs to the player rather than to the office. */
  .lbTable tr.lbMine td{ color:var(--gold); font-weight:700; }
  /* Somebody ranked past the top twenty-five still gets to see their own row,
     set apart from the list above it rather than pretending to be part of it. */
  .lbTable tr.lbGap td{ border-top:2px dashed var(--text-dim); padding-top:10px; }
  .lbYou{ margin-top:14px; padding-top:10px; border-top:3px solid var(--ink); font-family:'Press Start 2P',monospace; font-size:max(9px, calc(var(--ui) * 0.7059)); color:var(--gold); }
  .lbYou.lbYouMuted{ color:var(--text-dim); }
  .lbMsg{ color:var(--text-dim); padding:18px 0; text-align:center; }
  /* A sortable column heading. A button rather than a clickable th: the header
     is the only sorting control in the game and it has to be reachable from a
     keyboard. Styled to read as a heading until it is hovered. */
  .lbSort{ background:none; border:0; padding:0; margin:0; cursor:pointer; font:inherit; color:var(--text-dim); text-transform:inherit; letter-spacing:inherit; }
  .lbSort:hover{ color:var(--text); text-decoration:underline; }
  .lbSort.active{ color:var(--gold); }
  .lbSort.active::after{ content:' \25BC'; font-size:0.8em; }
  .lbRetry{ display:block; margin:10px auto 0; }

  /* ---- Corporate's review of the day ------------------------------------
     The rating is the loudest thing on the panel, because the whole joke is a
     grade nobody asked for. Coloured by band: red at the bottom, the gold the
     game already uses for anything that belongs to the player at the top. */
  #dayEndPanel{ max-width:calc(var(--ui) * 28); }
  .dayEndRating{ font-family:'Press Start 2P',monospace; font-size:max(10px, calc(var(--ui) * 0.8235));
    text-align:center; margin:4px 0 10px; line-height:1.5; }
  .dayEndRating.rating0{ color:var(--danger); }
  .dayEndRating.rating1{ color:#d98a3a; }
  .dayEndRating.rating2{ color:var(--text-dim); }
  .dayEndRating.rating3{ color:var(--good); }
  .dayEndRating.rating4{ color:var(--gold); }
  /* Corporate's own words. Italic, because it is a quotation from nobody. */
  .dayEndBlurb{ font-style:italic; color:var(--text-dim); text-align:center;
    margin-bottom:14px; line-height:1.6; }
  .dayEndIncident{ color:var(--danger); text-align:center; margin-bottom:14px; line-height:1.6; }
  .dayEndBtns{ margin-top:16px; }
  /* Says whether the share landed. Empty most of the time, so it holds its
     line rather than shifting the button when it fills. */
  .dayEndShareNote{ min-height:1.4em; margin-top:8px; text-align:center;
    color:var(--text-dim); font-size:max(9px, calc(var(--ui) * 0.7059)); }
  /* The Employee File's heading carries an invite button beside its title, so
     it is a row rather than a centred line. Everything else using .supportHead
     is unaffected -- only the file's own heading takes .headRow. */
  .supportHead.headRow{ display:flex; align-items:center; justify-content:center; gap:0.7em; }
  .empShareBtn{ background:var(--panel-light); border:2px solid var(--ink); color:var(--gold);
    box-shadow:2px 2px 0 var(--ink); cursor:pointer; padding:0.28em 0.4em; line-height:0;
    display:flex; align-items:center; flex:none; }
  .empShareBtn:hover{ color:var(--text); }
  .empShareBtn:active{ transform:translate(2px,2px); box-shadow:none; }
  .empShareBtn:focus-visible{ outline:2px solid var(--gold); outline-offset:2px; }
  .empShareBtn svg{ display:block; width:1.05em; height:1.05em; }
  .supportHead{ background:var(--panel-light); border-bottom:3px solid var(--ink); font-family:'Press Start 2P',monospace; font-size:max(8px, calc(var(--ui) * 0.7059)); color:var(--gold); padding:14px 16px; text-align:center; }
  /* Two of these can sit one under the other in the Applicant File -- SET UP
     EMPLOYEE FILE and RETURN TO TITLE MENU -- and the gap between the two rows
     read as a break in the panel rather than two buttons on the same errand. */
  #empApplySection .supportbtns{ margin-bottom:0; }
  .supportbtns{ display:flex; gap:10px; margin:16px 0; }
  /* The Employee File's way-out buttons, stacked rather than spaced. They were
     three separate .supportbtns rows, each carrying 16px above and below, which
     put 32px of nothing between every pair -- the owner: "remove the big
     unnecessary gap between the two buttons, they can just be stacked
     vertically." One margin around the stack, one gap inside it. */
  /* Directly under the career record, with the same 8px that separates the two
     buttons from each other, so the whole thing reads as one stack rather than
     as a block that has been pushed away from the file it belongs to. */
  .empActions{ display:flex; flex-direction:column; gap:8px; margin:0; }
  /* Each button in its own wrapper div, and a wrapper full of inline-block
     leaves a line-box's worth of descender space under it -- which put 18px
     between buttons that were asked to sit 8px apart. Flex wrappers have no
     line box to leave. */
  .empActions > div{ display:flex; }
  .empActions .supportbtn{ width:100%; }
  .supportbtn{ flex:1; background:var(--panel-light); border:2px solid var(--ink); color:var(--gold); font-family:'Press Start 2P',monospace; font-size:max(8px, calc(var(--ui) * 0.8235)); padding:14px 6px; cursor:pointer; box-shadow:3px 3px 0 var(--ink); }
  .supportbtn:active{ transform:translate(3px,3px); box-shadow:none; }
  .supportStepper{ flex:1; display:flex; align-items:stretch; }
  .supportStepper .stepperGive{ flex:1; min-width:0; display:flex; align-items:center; justify-content:center; border-right:none; }
  .supportStepper .stepperGive span{ overflow:hidden; text-overflow:clip; white-space:nowrap; }
  .stepperArrows{ display:flex; flex-direction:column; flex:0 0 22px; border:2px solid var(--ink); border-left:none; box-shadow:3px 3px 0 var(--ink); }
  .stepperArrowBtn{ flex:1; background:var(--panel-light); border:none; color:var(--gold); font-size:max(8px, calc(var(--ui) * 0.4706)); cursor:pointer; line-height:1; padding:0; }
  .stepperArrowBtn:first-child{ border-bottom:2px solid var(--ink); }
  .stepperArrowBtn:hover{ filter:brightness(1.2); }
  .stepperArrowBtn:active{ background:var(--ink); }
  .supportStep{ display:none; }
  .supportStep.show{ display:block; }
  .supportError{ color:#e58989; font-size:max(8px, calc(var(--ui) * 0.7647)); margin:8px 0 0; }
  .supportFieldLabel{ display:block; font-size:max(8px, calc(var(--ui) * 0.8235)); color:var(--text-dim); margin-bottom:4px; }
  /* Employee File panel + the (usually invisible) Turnstile slot under the start menu */
  /* The second-tab message. Sits with the start menu buttons, in the warning
     colour rather than the error colour: nothing has gone wrong, the game is
     just already open somewhere else. */
  /* Sits with the button it belongs to rather than down in the footer, which
     would read as legal boilerplate. Quiet on purpose: a caption, not a
     control, so the four buttons stay the only things that look pressable.
     The email truncates rather than wrapping -- a long address must not push
     "Sign out" onto a second line where nobody looks for it. */
  .signedInAs{ margin-top:12px; font-size:13px; color:var(--text-dim); text-align:center; max-width:100%; }
  .signedInAs .who{ display:inline-block; max-width:min(240px, 46vw); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:bottom; color:var(--text); }
  .signedInAs .sep{ opacity:.55; }
  .deskTakenNotice{ margin-top:14px; max-width:420px; font-size:14px; line-height:1.5; color:var(--warn); text-align:center; }
  /* Pinned to the bottom of the viewport rather than sitting in the title
     screen's flow, so it is reachable during a shift as well -- the Support
     panel needs a token too, and the title screen is display:none by then.
     'interaction-only' means Cloudflare renders nothing here until it actually
     wants the player to confirm something, so in the ordinary case this box
     has no content at all.
     Deliberately NOT the pointer-events:none-on-the-box /
     pointer-events:auto-on-the-iframe trick that used to be here. It never
     worked in ANY browser, not just Safari as first suspected: Turnstile's
     interactive challenge is not a plain <iframe> child of this box, so
     ".turnstileSlot iframe" matched nothing, and pointer-events:none on the
     box was blocking every click regardless. Confirmed directly with
     Cloudflare's own always-interactive test sitekey (3x00000000000000000000FF):
     elementFromPoint on the rendered challenge hit the page background with
     the old rule, and the widget itself with this one. width:fit-content
     makes the box exactly as big as whatever Cloudflare puts in it (nothing,
     most of the time), so there is never an
     invisible oversized area to worry about blocking clicks in the first
     place, and normal pointer-events works everywhere. */
  /* bottom is overwritten inline by positionTurnstileSlot() on browsers that
     expose window.visualViewport, to lift the widget clear of an open
     on-screen keyboard. This 8px stays the resting value and the fallback. */
  .turnstileSlot{ position:fixed; left:50%; transform:translateX(-50%); bottom:8px; z-index:9999; width:fit-content; display:flex; justify-content:center; opacity:1; visibility:visible; transition:opacity .4s ease; }
  .turnstileSlot iframe{ max-width:100%; }
  /* A finished challenge has no reason to keep sitting on screen; JS adds this
     class a couple seconds after it settles and removes it again before the
     next one (the widget is reused via turnstile.reset()).
     visibility:hidden alongside the fade, not opacity alone: this element is
     position:fixed WITH a transform, which puts it on its own compositing
     layer, and Safari has a long history of not repainting a cross-origin
     iframe inside one when only its ancestor's opacity changes -- the reported
     symptom being a solved challenge that never goes away. visibility is
     honoured by the compositor rather than the painter, so it cannot be missed
     that way, and it takes the widget out of hit-testing outright.
     The 0s-delayed-.4s transition holds visibility:visible until the opacity
     fade has finished, so the box still fades rather than vanishing; removing
     the class falls back to the base rule above, where visibility snaps back
     immediately and the next challenge is never invisible. */
  .turnstileSlot--done{ opacity:0; visibility:hidden; pointer-events:none; transition:opacity .4s ease, visibility 0s linear .4s; }
  /* Still in use: the Terms line under the sign-in form, and the note about the
     optional email on the feedback form. */
  .empNote{ font-size:max(8px, calc(var(--ui) * 0.7647)); color:var(--text-dim); margin:0 0 6px; }
  /* The caption under the buttons, in the size and colour that note has --
     smaller than body copy and in the dim ink -- because it is a footnote on
     the file rather than something anybody needs to read twice.
     The margin is the other half: a <p> carries a 1em top margin by default,
     and that was the space between it and SIGN OUT. 8px instead, the same gap
     the two buttons keep from each other, so the caption reads as the end of
     that stack rather than a paragraph adrift below it. */
  /* No margin of its own: the caption sits directly under the account block's
     top border, and the 14px between them is that block's padding -- the same
     14px that separates SIGN OUT from the line on the other side. The owner
     asked for exactly that symmetry, and a margin here would break it. */
  #empSignedInText{ font-size:max(8px, calc(var(--ui) * 0.7647)); color:var(--text-dim);
    margin:0; }
  .empStatus{ font-size:max(8px, calc(var(--ui) * 0.7647)); color:var(--text-dim); margin:10px 0 0; min-height:1.2em; }
  .emailOptInRow{ display:flex; align-items:center; gap:8px; font-size:max(8px, calc(var(--ui) * 0.8824)); cursor:pointer; margin-bottom:4px; }
  .emailOptInRow input{ width:16px; height:16px; cursor:pointer; flex-shrink:0; }
  .panelBody{ padding:14px 16px; overflow-y:auto; font-size:max(8px, calc(var(--ui) * 1.0)); }
  .statgrid{ display:grid; grid-template-columns:1fr auto; gap:6px 14px; margin-bottom:14px; }
  .statgrid .lbl{ color:var(--text-dim); }
  .statgrid .val{ text-align:right; }
  .sectionhead{ font-family:'Press Start 2P',monospace; font-size:max(8px, calc(var(--ui) * 0.7059)); color:var(--gold); margin:14px 0 8px 0; letter-spacing:1px; }

  /* stats overlay: three-tier hierarchy, deliberately its own visual style */
  .statsHeadline{ display:flex; gap:10px; margin-bottom:6px; }
  /* A button now, because each one opens the card explaining it. The reset is
     what keeps it looking exactly like the box it has always been rather than
     like a button, and min-width:0 stops the three of them refusing to shrink
     below their content on a narrow phone -- flex items default to auto. */
  .statsHeadline .stat{ flex:1; min-width:0; position:relative; background:var(--panel-light); border:2px solid var(--ink); padding:12px 8px; text-align:center;
    font:inherit; color:inherit; display:block; cursor:pointer; }
  /* A figure that opens an explanation has to look like it does. The same
     3px offset shadow every other button in a panel carries, and the same
     press -- players already read that as "this is a button", which a small
     question mark in the corner never quite did. */
  .statsHeadline .stat{ box-shadow:3px 3px 0 var(--ink); }
  .statsHeadline .stat:active{ transform:translate(3px,3px); box-shadow:none; }
  .statsHeadline .stat:hover{ border-color:var(--gold); }
  .statsHeadline .stat:focus-visible{ outline:2px solid var(--gold); outline-offset:2px; }
  .statsHeadline .statVal{ font-family:'Press Start 2P',monospace; font-size:max(8px, calc(var(--ui) * 1.0)); color:var(--gold); }
  .statsHeadline .statLbl{ font-size:max(8px, calc(var(--ui) * 0.7059)); color:var(--text-dim); margin-top:4px; text-transform:uppercase; letter-spacing:0.5px; }
  .statsCvToday{ margin-bottom:4px; }
  .statsRecord .val{ color:var(--text-dim); }
  /* The handbook's closing link strip. Styled after .startFooter, which it
     deliberately does not reuse -- that class is position:absolute against the
     bottom of the start screen and would tear straight out of this panel. */
  .handbookResources{ display:flex; flex-wrap:wrap; gap:6px 18px; justify-content:center; text-align:center; font-size:max(8px, calc(var(--ui) * 0.8824)); line-height:1.6; color:var(--text-dim); margin-top:4px; }
  #readmeOverlay .sectionhead{ margin-top:24px; }
  #readmeOverlay .sectionhead + p{ margin-top:0; }
  .daylist{ list-style:none; padding:0; margin:0; font-size:max(8px, calc(var(--ui) * 0.8824)); }
  .daylist li{ display:flex; justify-content:space-between; padding:3px 0; border-bottom:1px solid var(--panel-light); }
  .profit-pos{ color:var(--good); } .profit-neg{ color:var(--danger); }

  .cvBalanceBar{ display:flex; align-items:baseline; gap:8px; background:var(--panel-light); border:2px solid var(--ink); padding:10px 12px; margin-bottom:10px; }
  .cvBalanceBar span{ font-size:max(8px, calc(var(--ui) * 1.1765)); color:var(--gold); }
  .cvBalanceBar strong{ font-size:max(8px, calc(var(--ui) * 1.1765)); color:var(--gold); font-weight:normal; }
  .outfitnote{ font-size:max(8px, calc(var(--ui) * 0.8235)); color:var(--text-dim); margin-bottom:10px; }

  /* store: grid of cards (browse) + a single-item detail view swapped into
     the same panel body, mirroring the existing mail list/detail pattern */
  .storeGrid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(94px,1fr)); gap:10px; }
  .storeCard{ background:var(--panel-light); border:2px solid var(--ink); padding:10px 6px 8px; cursor:pointer; position:relative; display:flex; flex-direction:column; align-items:center; gap:5px; text-align:center; }
  .storeCard.equipped{ outline:2px solid var(--gold); outline-offset:2px; }
  .storeCard.dayblocked{ opacity:.5; }
  .storeCard .icon{ width:34px; height:34px; }
  /* The plant is drawn tall and narrow inside its 24x24 box -- the art spans
     about 38% of the box's width -- so at the shared 34px it reads far smaller
     than icons that fill their box corner to corner. It gets its own size here
     rather than a wider drawing, because the drawing's proportions are pinned
     by the floor geometry (see ITEM_ICONS.plant). The negative margins keep the
     card the same height as its neighbours in the grid. */
  /* An office is a picture of a room, not a 24x24 pictogram, so its card is
     twice as wide as everything else and carries a real thumbnail. Three of
     them span the six columns a widened panel gives, so the whole range is on
     screen at once; a fourth would wrap to the next row on its own. */
  /* Offices are pictures of rooms, so they get their own row rather than a
     cell in the grid: one line, twice the width each, scrolling sideways. Three
     fit today and the row simply grows -- which is the point, since the whole
     shop should not have to get wider every time an office is added. */
  .storeGrid.officeRow{ display:flex; grid-template-columns:none; overflow-x:auto; scroll-snap-type:x proximity; padding-bottom:6px; }
  .storeGrid.officeRow .storeCard{ flex:0 0 clamp(150px, 42%, 230px); scroll-snap-align:start; }
  .storeGrid.officeRow::-webkit-scrollbar{ height:6px; }
  .storeGrid.officeRow::-webkit-scrollbar-thumb{ background:var(--text-dim); }
  .storeGrid.officeRow::-webkit-scrollbar-track{ background:transparent; }
  .storeCard.officeCard .officeThumb{ width:100%; aspect-ratio:4/3; display:block; border:2px solid var(--ink); background:#8f9296; object-fit:cover; }
  .storeCard.officeCard.locked .officeThumb{ filter:grayscale(0.35) brightness(0.82); }
  .storeDetail .officeThumb{ width:100%; max-width:340px; aspect-ratio:4/3; display:block; margin:0 auto 8px; border:3px solid var(--ink); }
  .storeCard[data-key="plant"] .icon{ width:54px; height:54px; margin:-8px 0 -6px; }
  .storeCard .nm{ font-size:max(8px, calc(var(--ui) * 0.7059)); line-height:1.25; min-height:2.4em; display:flex; align-items:center; }
  .storeCard .price{ font-size:max(8px, calc(var(--ui) * 0.7059)); color:var(--gold); }
  /* Not for sale yet. Greyed rather than hidden: the point is that the player
     can see what is coming, not that the shelf looks empty. */
  .storeCard.soon{ opacity:0.55; cursor:default; }
  .price.soon{ color:var(--text-dim); letter-spacing:0.5px; }
  .storeCard .tag{ font-size:max(8px, calc(var(--ui) * 0.5882)); color:var(--warn); line-height:1.2; margin-top:1px; }
  .storeCard .statechip{ position:absolute; top:3px; right:3px; font-size:max(8px, calc(var(--ui) * 0.5294)); text-transform:uppercase; letter-spacing:0.5px; padding:2px 4px; border:1px solid var(--ink); background:var(--panel); color:var(--text-dim); }
  .storeCard .statechip.equipped{ background:var(--gold); color:var(--ink); border-color:var(--ink); }
  .storeCard .previewMini{ position:absolute; top:3px; left:3px; font-size:max(8px, calc(var(--ui) * 0.5882)); width:18px; height:18px; padding:0; display:flex; align-items:center; justify-content:center; background:var(--panel); border:1px solid var(--ink); color:var(--text); cursor:pointer; }
  .storeCard .previewMini.stop{ background:var(--danger); color:#fff; }

  .storeDetail{ text-align:center; padding:4px 6px; }
  .storeDetail .back{ float:left; background:var(--panel-light); border:2px solid var(--ink); color:var(--text); font-size:max(8px, calc(var(--ui) * 0.8235)); padding:5px 10px; cursor:pointer; margin-bottom:14px; }
  .storeDetail .icon{ width:88px; height:88px; margin:8px auto 12px; clear:both; }
  .storeDetail .nm{ font-size:max(8px, calc(var(--ui) * 1.1176)); color:var(--gold); margin-bottom:6px; }
  .storeDetail .price{ font-size:max(8px, calc(var(--ui) * 0.9412)); color:var(--text); margin-bottom:8px; }
  .storeDetail .tag{ font-size:max(8px, calc(var(--ui) * 0.7647)); color:var(--warn); margin-bottom:4px; }
  .storeDetail .note{ font-size:max(8px, calc(var(--ui) * 0.7647)); color:var(--text-dim); margin:8px 0; }
  .storeDetail .usdbadge{ display:inline-block; font-size:max(8px, calc(var(--ui) * 0.7059)); color:var(--text-dim); background:var(--panel); border:1px dashed var(--text-dim); border-radius:3px; padding:5px 10px; margin:10px 0; cursor:pointer; }
  .storeDetail .previewBtn{ display:block; width:100%; font-size:max(8px, calc(var(--ui) * 0.7647)); color:var(--text); background:var(--panel); border:2px solid var(--ink); padding:9px 10px; margin-bottom:10px; cursor:pointer; font-family:inherit; }
  .storeDetail .actionBtn{ display:block; width:100%; background:var(--good); border:3px solid var(--ink); color:var(--ink); font-family:'Press Start 2P',monospace; font-size:max(8px, calc(var(--ui) * 0.7059)); padding:14px 10px; cursor:pointer; box-shadow:4px 4px 0 var(--ink); letter-spacing:0.5px; margin-top:6px; }
  .storeDetail .actionBtn:disabled{ background:var(--panel); color:var(--text-dim); cursor:not-allowed; box-shadow:none; }

  /* Above the tutorial layer (70), not below it. A toast fired DURING the tour
     -- the refusal when a locked icon is clicked, the thank-you when an
     Applicant finishes a pile of paper -- used to be painted under the dim and
     under the card, which is where the owner found them: legible only if you
     already knew they were there. A toast is a four-second message; if it is
     worth showing at all it is worth showing on top. Still below the Turnstile
     slot (9999), so a robot check is never buried. */
  .toastwrap{ position:absolute; top:8px; left:50%; transform:translateX(-50%); display:flex; flex-direction:column; gap:6px; z-index:80; align-items:center; pointer-events:none; }
  .toast{ background:var(--gold); color:var(--ink); border:2px solid var(--ink); font-size:14px; padding:6px 12px; box-shadow:3px 3px 0 rgba(0,0,0,.5); animation:toastIn 4.5s ease forwards; }
  @keyframes toastIn{ 0%{opacity:0; transform:translateY(-8px);} 8%{opacity:1; transform:translateY(0);} 85%{opacity:1;} 100%{opacity:0; transform:translateY(-6px);} }
  /* same toast, positioned at a specific point (e.g. the cursor) instead of
     the fixed top-center wrap -- left/top set inline per instance in JS. */
  .toastNear{ position:absolute; z-index:80; pointer-events:none; white-space:nowrap; }
  /* ---- guided tutorial ----
     The layer covers the whole cabinet at a z-index above every overlay (30),
     but BELOW the toasts (80) -- a four-second message has to be readable over
     the dim -- and below the Turnstile slot (9999) so a robot check is never
     buried. pointer-events:none is the load-bearing part: the
     player has to be able to actually click the button being pointed at, so
     only the card itself takes input. */
  /* The layer itself catches nothing. What blocks the office is the dim
     panels inside it, and what does NOT block it is the gaps between them --
     see .tutorialDim. */
  .tutorialLayer{ position:absolute; inset:0; z-index:70; display:none; pointer-events:none; }
  /* ---- the dim, and the ropes ----
     The dim used to be one enormous shadow cast outward by the ring, which
     made the ring and the dim the same object. That bought a cut-out for free
     and cost everything else: a shadow cannot be clicked, so nothing outside
     the highlight was ever actually blocked -- a player could press Return to
     Title mid-tour and break it -- and a shadow has exactly one hole, so a
     step could never light two things at once, nor light something that had to
     stay unclickable.

     It is panels now: opaque rectangles tiling the office AROUND the holes,
     recomputed with the rings. They darken and they swallow clicks, which are
     the same job. A hole is simply a gap nothing was painted over -- full
     brightness, and the click lands on the real game underneath. */
  /* Underneath everything else in the layer, and that is not cosmetic. The
     panels are appended to the layer as they are needed, which puts them AFTER
     the card in document order -- and with no z-index that means on top of it,
     so the tour's own buttons stop responding. Found by hit-testing rather than
     by looking: the card still LOOKS right, because the panels tiling around a
     hole leave the card's area painted exactly once either way. */
  .tutorialDim{ position:absolute; background:rgba(8,8,6,0.72); pointer-events:auto; z-index:0; }
  /* A hole that must be SEEN but not touched -- a panel being explained before
     the player is meant to poke at it. Invisible, and it stops the click. */
  .tutorialHoleBlock{ position:absolute; background:transparent; pointer-events:auto; z-index:1; }
  /* The opposite of a lid: a hole painted over so it does not read as a
     highlight, while clicks still reach straight through it. pointer-events is
     the whole trick. */
  .tutorialShade{ position:absolute; background:rgba(8,8,6,0.72); pointer-events:none; z-index:1; }
  .tutorialLayer.clear .tutorialShade,
  .tutorialLayer.lit .tutorialShade{ background:transparent; }
  /* The opening beat: the office at full brightness with nothing on it, and
     still nothing clickable. The panels are all there and still blocking; they
     just aren't painted. */
  .tutorialLayer.clear .tutorialDim{ background:transparent; }
  /* Lit but locked: the office at full brightness with the panels still there
     swallowing every click. The difference from `clear` is that this one leaves
     the card and its buttons on screen -- it is for a card the player is meant
     to read while they watch, not for a beat with nothing on it. */
  .tutorialLayer.lit .tutorialDim{ background:transparent; }
  /* A quiet beat: the office roped off and empty while something happens in it. */
  .tutorialLayer.beat .tutorialRing,
  .tutorialLayer.beat .tutorialRing2{ display:none !important; }
  .tutorialLayer.clear .tutorialCard,
  .tutorialLayer.clear .tutorialRing,
  .tutorialLayer.clear .tutorialRing2{ display:none !important; }
  /* Lifted over the layer so it keeps working while everything else is inert.
     .cabinet is position:relative with no z-index, so this and the layer share
     a stacking context and 80 beats 70. Only the opening beat and the welcome
     card grant this; from the first real step the panels cover it like
     everything else, and the way out is the skip line under the card. */
  .tutorialEscape{ position:relative; z-index:80; }
  .tutorialLayer.show{ display:block; }
  /* Just the gold frame now. The dim it used to cast lives in .tutorialDim. */
  .tutorialRing{ position:absolute; z-index:2; border:3px solid var(--gold); pointer-events:none; transition:top .18s ease, left .18s ease, width .18s ease, height .18s ease; }
  .tutorialRing.pulse{ animation:tutorialPulse 1.6s ease-in-out infinite; }
  @keyframes tutorialPulse{ 0%,100%{ border-color:var(--gold); } 50%{ border-color:#fff3c4; } }
  /* A step that points at nothing still has a ring element; it just shows no
     frame. The dim no longer depends on it existing anywhere in particular,
     which is what used to make this fiddly. */
  .tutorialRing.noTarget{ display:none; }
  /* ONE number sizes this whole card. Everything inside is expressed in em, so
     it all rides this base -- which means the card's proportions are fixed and
     only its scale changes with the window.

     The middle term is the real behaviour: 2.217cqw is 17px in the owner's own
     ~864px-wide window, which is the size all of this was designed against, so
     nothing moves for him. Bigger windows scale up from there; smaller ones
     scale down until the floor catches them.
     Calibrated against the cabinet's CONTENT box -- container units exclude its
     border and padding, which is 28px of the 795 it measures on screen.

     The floor is not decoration. Pure proportional scaling puts this at 7px on
     a 360px phone, and shrinking below readable was never the point of any of
     this -- on a small screen the answer is layout, not smaller type. The
     ceiling stops a 1000px cabinet from turning the tour into a billboard. */
  .tutorialCard{ font-size:var(--ui); }
  /* Width the same way: proportional through the middle, with a floor so the
     card does not turn into a column on a phone, and never wider than the
     office it sits in. */
  .tutorialCard{ position:absolute; z-index:3; left:50%; transform:translateX(-50%); width:min(88%, max(280px, 56.06cqw)); background:var(--panel); border:3px solid var(--ink); box-shadow:6px 6px 0 rgba(0,0,0,0.5); padding:0.82em 0.94em 0.71em; pointer-events:auto; text-align:center; }
  .tutorialCard.atTop{ top:16px; }
  .tutorialCard.atBottom{ bottom:16px; }
  /* A step that names its own spot. The two classes above are the fallback --
     they only choose a half of the screen, which is why boxes kept ending up
     across the cubicle from the thing they were pointing at. `.placed` clears
     them and lets tutorialReposition write real coordinates. */
  .tutorialCard.placed{ top:auto; bottom:auto; }
  /* A card can be taller than the office it sits in on a short phone -- the
     opening one is. Cap it and let its own contents scroll rather than letting
     the buttons fall off the bottom of the screen. No effect anywhere the card
     already fits, which is every desktop. */
  .tutorialCard{ max-height:calc(100% - 2em); overflow-y:auto; }
  /* A card that must stay on the left half of the office. It is placed hard
     left and its width is capped so its right edge can never cross the middle
     of the play area -- the owner's rule for the Email card: "do not move the
     right edge of the card past the x 50% coordinate ... It needs to stay on
     the left side of the screen and never cover the computer screen."

     44cqw rather than 50: the play area is inset within the cabinet the
     container units measure, and the card starts at its left wall rather than
     the cabinet's. */
  .tutorialCard.leftHalf{ width:min(88%, max(240px, 38cqw)); }
  .tutorialCard.placedRight{ left:auto; transform:none; }
  /* A card whose left edge is placed explicitly, rather than being centred
     across the office. transform:none because the centring is a -50% shift. */
  .tutorialCard.placedLeft{ transform:none; }
  /* max(), not a bare fraction: these three are the smallest text on the card,
     and by the time the base has bottomed out on a phone a plain 0.53em of it
     is 7px. Above the floor they scale in lockstep with everything else. */
  .tutorialProgress{ font-family:'Press Start 2P',monospace; font-size:max(8px, 0.53em); color:var(--gold); letter-spacing:1px; margin-bottom:0.47em; }
  /* The opening card is a panel rather than a step, so it wears the same gold
     header band every other panel in the game wears. Bled out to the card's
     edges with negative margins that undo .tutorialCard's own padding. */
  .tutorialHead{ display:none; margin:-0.82em -0.94em 0.71em; font-size:max(9px, 0.65em); }
  .tutorialCard.hasHead .tutorialHead{ display:block; }
  /* pre-line so a step can hold a paragraph break. Everything else in the
     script is a single paragraph and is unaffected. */
  /* Centred, like everything else on the card.

     This has been round three times. Justified first, which on a box this
     narrow opened rivers of white space and stretched short lines to the
     margins -- the owner called it "weird issues". Then flush left, which reads
     well under a full paragraph and badly under the one-line instructions that
     make up most of the tour: a single short sentence hard against the left
     edge of a centred card looks like a mistake. Centred is what the title, the
     buttons and the skip line already do, so the card now has one axis instead
     of two. */
  .tutorialText{ font-size:1em; line-height:1.5; color:var(--text); margin-bottom:0.71em; white-space:pre-line; text-align:center; }
  /* Two paragraphs of prose centred line by line reads as a poster rather than
     as writing, and the ragged edges fight the eye on the way down. Only the
     cards that carry real copy ask for this; the one-line instructions are
     short enough that centring them still looks deliberate. */
  /* Kept as the escape hatch for a card that ever needs to break ranks. No
     step sets it today. */
  .tutorialCard.alignLeft .tutorialText{ text-align:left; }
  /* The failsafe navigation, floated under whatever the hidden card got out of
     the way of. Same three controls in the same order as the card's own, so it
     reads as the card's bottom edge rather than as a new thing to learn.
     Deliberately no panel behind it: it sits on a lit hole, and a box drawn
     round it would look like part of the email it is underneath. */
  .tutorialFloatBar{ position:absolute; z-index:3; left:50%; transform:translateX(-50%); font-size:var(--ui); pointer-events:auto; text-align:center; }
  .tutorialBtns{ display:flex; gap:0.47em; justify-content:center; flex-wrap:wrap; }
  /* The job offer at the end stacks its two answers instead of setting them
     side by side, so accepting reads as the decision and declining as the
     alternative underneath it rather than its equal. Both stay one line tall --
     THINK ABOUT IT is the longer label and must not wrap, or the two buttons
     end up different heights. */
  .tutorialBtns.stacked{ flex-direction:column; align-items:center; }
  /* Wider than they were, and centred. CONTINUE AS APPLICANT is the longest
     label in the tour and it was sitting hard against the right edge of its own
     button -- .startbtn is a flex child elsewhere, so it inherited an alignment
     that only makes sense in a row. */
  .tutorialBtns.stacked .startbtn{ width:min(25em, 100%); white-space:nowrap; text-align:center; }
  /* em against the CARD's base, not against the button's own font-size, which
     is what an em inside a font-size declaration would mean. Hence the padding
     is written in em of the button's own (already scaled) size. */
  /* The failsafe row is a SIBLING of the card, not a child of it, so this rule
     used to miss it entirely and its two buttons rendered at the game's full
     button size -- visibly chunkier than the identical pair on every card. */
  .tutorialCard .startbtn,
  .tutorialFloatBar .startbtn{ font-size:max(9px, 0.65em); padding:0.91em 1.27em; }
  /* The quiet way forward on a step that is waiting for the player to do
     something. Always present, so nobody can be trapped by a step whose target
     they cannot reach. */
  /* The one way past the sign-in desk, so it has to read as a real offer
     rather than fine print -- but quieter than SEND CODE, which is still the
     thing most people should press. The link is the only clickable part. */
  .empApplicantNote{ margin:10px 0 0; text-align:center; color:var(--text-dim); font-size:max(8px, calc(var(--ui) * 0.8824)); line-height:1.4; }
  .empApplicantNote a{ color:#7fc4ef; text-decoration:underline; }
  .empApplicantNote a:hover{ color:#a9dcff; }
  /* The way out, on its own line under the buttons. GO BACK sits up in the
     button row with the card's own answers -- it is a thing you press to move
     around the tour, not a footnote. */
  .tutorialNav{ display:flex; justify-content:center; align-items:center; margin-top:0.59em; }
  .tutorialSkip{ display:block; margin:0; background:none; border:none; color:var(--text-dim); font-family:'VT323',monospace; font-size:max(11px, 0.88em); text-decoration:underline; cursor:pointer; }
  .tutorialSkip:hover{ color:var(--gold); }
  /* The same shape and size as the card's own answers, in the quieter colour
     the Terms gate's DECLINE uses -- so a row of three reads as two answers
     and a way back rather than three equal choices.
     Leftmost, because the way forward belongs on the right: that is where a
     reader of English expects the next thing to be, and every wizard and
     installer they have ever used puts it there. */
  .tutorialBack{ background:var(--panel-light); color:var(--text); }
  /* Except when the buttons stack, where "first" is top rather than left and
     the way out does not belong above the offer. */
  .tutorialBtns.stacked .tutorialBack{ order:1; }
  /* On a card that names its own way out, that is an answer rather than a
     navigation control -- so it wears the same green as the answer beside it. */
  .tutorialBack.asAnswer{ background:var(--good); color:var(--ink); }
  /* When a step names its own third answer it is a real choice rather than a
     way out, so it reads as a button instead of a footnote. */
  .tutorialSkip.asButton{ font-family:'Press Start 2P',monospace; font-size:max(9px, 0.59em); color:var(--gold); text-decoration:none; border:2px solid var(--ink); background:var(--panel-light); padding:0.9em 1.4em; box-shadow:3px 3px 0 var(--ink); }
  .tutorialSkip.asButton:active{ transform:translate(3px,3px); box-shadow:none; }

  /* ---- a phone: the card docks UNDER the office instead of on it ----------
     On a phone held upright the office is about 331x248, and this card's
     readable floor (280px wide, 13px type, set above) covers most of it
     wherever it is put -- and every hand-measured `place` coordinate in the
     script was taken on a desktop, so none of them mean anything at that size.
     So on a phone it is not put in the office at all: it becomes a sheet
     pinned to the bottom of the screen, below the cabinet, and the ring and
     the dim do the pointing on their own. There is ~350px of empty screen
     under the cabinet on an 844px phone doing nothing else.

     game.js decides, at the same 620px line Solitaire leaves the cabinet at,
     and toggles both classes here -- .docked on the card and .tourDocked on
     the body. Deliberately NOT body:has(.tutorialCard.docked): :has() only
     reached Firefox in December 2023, nearly a year after the container
     queries this game already needs, and its failure mode here would be the
     sheet sitting on the office -- exactly the bug this fixes.

     Last in the section on purpose: it ties with .leftHalf, .atTop and
     .atBottom on specificity and has to win on order. (game.js strips those
     classes while docked as well -- belt and braces.) */
  .tutorialCard.docked{
    position:fixed; left:0; right:0; bottom:0; top:auto;
    width:auto; max-width:none; transform:none;
    max-height:40vh; overflow-y:auto;
    border-width:3px 0 0; box-shadow:0 -6px 0 rgba(0,0,0,0.5);
  }
  /* While the sheet is up the office moves to the top of the screen and the
     page keeps the sheet's room free underneath it, so the sheet can never sit
     on the coffee button -- the page scrolls instead, the way the short-screen
     rule near .cabinet already lets it.

     --tourDockH is the sheet's MEASURED height, written by tutorialSetDockRoom
     every tick. A flat 40vh would reserve 256px on a 640px phone for a card
     that is often nearer 160, and every pixel over-reserved is a pixel the
     player has to scroll past to reach the bottom of the office. The fallback
     covers the tick before the first measurement lands. */
  body.tourDocked{ align-items:flex-start; padding-bottom:calc(var(--tourDockH, 40vh) + 16px); }
  /* Both, and set from JS rather than with :has() for the reason above. The
     short-screen rule near .cabinet already frees these two together; the page
     needs to be allowed to grow past the viewport for the reserved room below
     the office to be reachable at all. */
  html.tourDocked, body.tourDocked{ height:auto; }

  /* The one toast that does not fade -- see showReloadToast in game.js. */
  .toast.persistent{ animation:none; opacity:1; display:flex; align-items:center; gap:10px; }
  .toastReloadBtn, .toastDismissBtn{ font-family:'VT323',monospace; background:var(--ink); color:var(--gold); border:2px solid var(--ink); padding:2px 10px; cursor:pointer; font-size:14px; }
  .toastDismissBtn{ padding:2px 7px; color:var(--text-dim); }
  .toastReloadBtn:hover{ background:#33352a; }

  /* inbox */
  .mailTabs{ display:flex; border-bottom:3px solid var(--ink); }
  .mailTab{ flex:1; background:var(--panel-light); border:none; color:var(--text-dim); font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 0.7647)); padding:10px 4px; cursor:pointer; }
  .mailTab.active{ background:var(--panel); color:var(--gold); }
  .mailTab .cnt{ background:var(--danger); color:#fff; border-radius:8px; padding:0 5px; font-size:max(8px, calc(var(--ui) * 0.6471)); margin-left:5px; }
  .mailList{ list-style:none; margin:0; padding:0; }
  .mailRow{ padding:9px 6px; border-bottom:1px solid var(--panel-light); cursor:pointer; display:flex; flex-direction:column; gap:2px; }
  .mailRow:hover{ background:var(--panel-light); }
  .mailRow .top{ display:flex; justify-content:space-between; font-size:max(8px, calc(var(--ui) * 0.8235)); color:var(--text-dim); }
  .mailRow .subj{ font-size:max(8px, calc(var(--ui) * 0.9412)); }
  .mailRow.unread .subj{ font-weight:700; color:var(--text); }
  .mailRow.unread .top{ color:var(--gold); }
  .mailEmpty{ color:var(--text-dim); padding:16px 4px; }
  .mailDetail .back{ background:var(--panel-light); border:2px solid var(--ink); color:var(--text); font-size:max(8px, calc(var(--ui) * 0.8235)); padding:5px 10px; cursor:pointer; margin-bottom:10px; }
  .mailDetail .from{ color:var(--text-dim); font-size:max(8px, calc(var(--ui) * 0.8235)); }
  .mailDetail .subj{ font-size:max(8px, calc(var(--ui) * 1.1176)); margin:4px 0 10px 0; color:var(--gold); }
  /* pre-wrap so an email body can use blank lines between paragraphs (the
     onboarding email does). Every other body is a single line with no \n in it,
     so none of them render any differently for it. */
  .mailDetail .body{ font-size:max(8px, calc(var(--ui) * 1.0)); line-height:1.4; white-space:pre-wrap; }
  /* A real &lt;button&gt; dressed as a link, so it sits inside a sentence in the
     body text. Inherits the body's font and size deliberately -- it should read
     as part of the memo, not as a control bolted underneath it. */
  .mailInlineLink{ display:inline; background:none; border:none; padding:0; margin:0;
    font-family:inherit; font-size:inherit; line-height:inherit; color:#4da6ff;
    text-decoration:underline; cursor:pointer; white-space:normal; }
  .mailInlineLink:hover{ color:#7cc0ff; }
  .replyBox{ margin-top:16px; padding-top:12px; border-top:2px dashed var(--panel-light); }
  /* An answered email keeps its dashed divider so the detail view doesn't
     visibly shrink, but says so plainly where the compose box used to be. */
  .replyBox.replied{ color:var(--text-dim); font-size:max(8px, calc(var(--ui) * 0.8824)); letter-spacing:0.5px; }
  .replyBox textarea{ width:100%; min-height:70px; background:var(--panel-light); border:2px solid var(--ink); color:var(--text); font-family:'VT323','Courier New',monospace; font-size:max(8px, calc(var(--ui) * 0.9412)); padding:8px; resize:vertical; margin-bottom:8px; }

  /* ---- attachment viewer (a deck that arrived by email) ----
     The stage keeps the 4:3 shape a deck is drawn at and scales the SVG to fit
     whatever room the panel has, so the same slides read on a phone and on a
     desktop without a second layout. min-height:0 on the body is what lets the
     stage shrink inside the flex column instead of pushing the nav bar off. */
  .deckBox{ max-width:min(920px, 94vw); }
  .deckBody{ display:flex; flex-direction:column; min-height:0; gap:10px; }
  .deckStage{
    flex:1; min-height:0; display:flex; align-items:center; justify-content:center;
    background:var(--ink); border:2px solid var(--ink); overflow:hidden;
  }
  .deckStage svg{ display:block; width:100%; height:100%; max-height:70vh; object-fit:contain; }
  .deckBar{ display:flex; align-items:center; justify-content:space-between; gap:10px; flex-shrink:0; }
  .deckNav{ font-size:11px; padding:8px 14px; }
  .deckNav[disabled]{ opacity:0.4; cursor:default; }
  .deckCount{ font-size:max(8px, calc(var(--ui) * 0.7647)); color:var(--text-dim); letter-spacing:1px; }
  /* The approval button only exists on the last slide, and it is the only
     control the deck ever offers. */
  .deckApprove{ font-size:11px; padding:8px 18px; }
  .deckApproved{ font-size:max(8px, calc(var(--ui) * 0.7647)); color:var(--text-dim); }
  /* The paperclip line under an email body. */
  .mailAttach{
    margin-top:14px; padding-top:12px; border-top:2px dashed var(--panel-light);
    display:flex; align-items:center; gap:10px; flex-wrap:wrap;
  }
  .mailAttachBtn{
    display:flex; align-items:center; gap:8px; background:var(--panel-light);
    border:2px solid var(--ink); box-shadow:2px 2px 0 var(--ink); color:var(--text);
    font-family:inherit; font-size:max(8px, calc(var(--ui)*0.8235)); padding:8px 12px;
    cursor:pointer; text-align:left; max-width:100%;
  }
  .mailAttachBtn:hover{ background:var(--panel); }
  /* The filename is the long part and the part worth reading, so it is the one
     allowed to wrap; the size stays on one line beside it. */
  .mailAttachName{ overflow-wrap:anywhere; }
  .mailAttachSize{ color:var(--text-dim); white-space:nowrap; }

  /* desktop shell (hidden -- reachable only via the /desktop terminal command) */
  .desktopBox{ max-width:calc(var(--ui) * 41.6431); aspect-ratio:4/3; }
  /* container-type:size (not the more common inline-size) because the fix
     below responds to this element's HEIGHT, not its width -- see the
     @container rule near .desktopIcon. */
  .desktopScreen{ container-type:size; container-name:desktopScreen; position:relative; flex:1; min-height:0; margin:14px 16px 16px; border:2px solid var(--ink); background:radial-gradient(120% 140% at 30% 12%, #322f27 0%, var(--monitor) 55%, #15130e 100%); overflow:hidden; box-shadow:inset 0 0 40px 12px rgba(0,0,0,0.55); }
  .desktopScreen::before{ content:''; position:absolute; inset:0; pointer-events:none; background:repeating-linear-gradient(180deg, rgba(0,0,0,0.16) 0px, rgba(0,0,0,0.16) 1px, transparent 2px, transparent 4px); }
  .desktopScreen::after{ content:''; position:absolute; right:10px; bottom:8px; width:6px; height:6px; border-radius:50%; background:var(--monitor-glow); box-shadow:0 0 5px 2px var(--monitor-glow); }

  /* desktop login gate: shown instead of desktopScreen outside office hours --
     same physical "screen" (identical background/scanlines) but no login here
     ever succeeds, since it's a UI block, not real auth. */
  /* overflow-y:auto, not hidden. Measured on 2 Sep 2026: at 320px this screen
     is 171px tall and the login box is 244px even with no logo in it, so the
     note about the Access Code was being cut in half on the narrowest phone
     and had been for as long as the screen has existed. Scrolling is the
     honest fix -- clipping a sentence that tells the player the password is
     not a thing to leave in. align-items:flex-start goes with it: a centred
     flex child that overflows is clipped at BOTH ends and cannot be scrolled
     back up to. */
  .desktopLoginView{ position:relative; flex:1; min-height:0; margin:14px 16px 16px; border:2px solid var(--ink); background:radial-gradient(120% 140% at 30% 12%, #322f27 0%, var(--monitor) 55%, #15130e 100%); overflow-x:hidden; overflow-y:auto; box-shadow:inset 0 0 40px 12px rgba(0,0,0,0.55); display:flex; align-items:flex-start; justify-content:center; padding:10px 0; }
  /* Back to vertically centred wherever there is actually room for it. */
  @media (min-height: 600px){
    @container cabinet (min-width: 520px){
      .desktopLoginView{ align-items:center; }
    }
  }
  .desktopLoginView::before{ content:''; position:absolute; inset:0; pointer-events:none; background:repeating-linear-gradient(180deg, rgba(0,0,0,0.16) 0px, rgba(0,0,0,0.16) 1px, transparent 2px, transparent 4px); }
  .loginBox{ position:relative; width:230px; text-align:center; }
  .loginBox.shake{ animation:loginShake 0.4s; }
  @keyframes loginShake{ 0%,100%{ transform:translateX(0); } 20%{ transform:translateX(-6px); } 40%{ transform:translateX(6px); } 60%{ transform:translateX(-4px); } 80%{ transform:translateX(4px); } }
  /* The platform's wordmark, and the screen's title -- there is no text title
     any more, because the logo says SYNERGY and the button says LOG IN, so a
     third label between them was saying it a second time and costing the room
     the note at the bottom needs.
     118px is what fits the 230px box without pushing that note off the screen,
     with 8px to spare;
     measured against the rendered panel, not guessed. image-rendering:pixelated
     keeps the pixel art crisp at the half-scale it is drawn at -- the browser's
     default smoothing turns the wordmark to mush. */
  .loginLogo{ display:none; width:118px; max-width:100%; margin:0 auto 14px; image-rendering:pixelated; }
  /* 520cqw is the cabinet width at which the monitor is tall enough to take
     the wordmark and still show the note underneath. Below it -- every phone --
     the logo is dropped rather than shrunk: at the size it would have to be it
     is illegible anyway, and the two fields and the note are what the screen is
     for. Measured, not guessed: at 390px the box fits in 235px of screen with
     4px to spare BEFORE any logo, so there was never room for one there. */
  @container cabinet (min-width: 520px){
    .loginLogo{ display:block; }
  }
  .loginTitle{ font-family:'Press Start 2P',monospace; font-size:max(8px, calc(var(--ui) * 0.7059)); color:var(--monitor-glow); margin-bottom:16px; letter-spacing:1px; }
  .loginRow{ text-align:left; margin-bottom:10px; }
  .loginRow label{ display:block; font-size:max(8px, calc(var(--ui) * 0.7059)); color:var(--text-dim); margin-bottom:3px; text-transform:uppercase; letter-spacing:0.5px; }
  .loginRow input{ width:100%; box-sizing:border-box; background:#15130e; border:2px solid var(--monitor-glow); color:var(--monitor-glow); font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 0.8235)); padding:7px 9px; }
  .loginRow input:focus{ outline:none; }
  .loginRow input:focus-visible{ outline:2px solid var(--gold); outline-offset:1px; }
  /* Safari on iOS zooms the entire page in when a text field smaller than 16px
     takes focus, and does not zoom back out afterwards -- so one tap on the
     terminal leaves the player stranded in a magnified game. Only on touch, so
     the deliberate small type survives on a desktop. */
  @media (pointer: coarse){
    /* 16px is not a preference, it is the exact threshold iOS uses: at 15.99px
       it zooms, at 16px it does not. The floor was 8px here, which meant the
       rule read as a zoom fix while never actually clearing the bar -- measured
       at 12.2px on an iPhone 14, and the page duly zoomed on every tap into the
       sign-in email, the six-digit code, the reply box and the TPS initials. */
    /* .replyBox textarea is listed by name because it has to be. This rule's
       bare `textarea` is one element selector; the reply box's own font-size
       further up is a class AND an element, which outranks it -- so the one
       field this fix was mainly written for was the one field it never
       reached. Measured on an iPhone 12: every other input resolved to 16px
       and the reply box to 12.24px, which is why replying to Natalie left the
       game zoomed in with the onboarding card cropped off the bottom. */
    .terminalInput, .loginRow input, .formfield, textarea.formfield, .replyBox textarea, input[type="text"], input[type="email"], input[type="number"], textarea, select{ font-size:max(16px, calc(var(--ui) * 0.9412)); }
  }

  .loginSubmitBtn{ width:100%; margin-top:6px; font-size:max(8px, calc(var(--ui) * 0.7059)); padding:10px; }
  .loginMsg{ min-height:16px; margin-top:10px; font-size:max(8px, calc(var(--ui) * 0.7059)); color:var(--danger); font-weight:700; }
  .loginNote{ margin-top:14px; font-size:max(8px, calc(var(--ui) * 0.7059)); color:var(--text-dim); line-height:1.4; }
  /* min(90px, 15%): the two icon columns below sit at left:3% and left:18%, so
     15% of .desktopScreen's width is exactly the column gap -- at any width
     that keeps 15% >= 90px this is just 90px (unchanged from before), but
     below that (a phone-narrow .cabinet) it shrinks with the column spacing
     instead of overflowing into the next column. Found and fixed during the
     mobile runtime pass (audit item (c)): at 359px cabinet width, "Company
     Handbook" and "Solitaire" (and "Email" and "TPS Reports") overlapped into
     illegible garbled text -- not a squint-and-manage case, a wrong-icon-tap
     case. */
  .desktopIcon{ position:absolute; display:flex; flex-direction:column; align-items:center; gap:calc(var(--ui) * 0.2709); width:min(calc(var(--ui) * 6.0951), 15%); background:none; border:none; cursor:grab; touch-action:none; padding:2px; }
  .desktopIcon .glyph{ font-size:max(11px, calc(var(--ui) * 1.693)); width:max(18px, calc(var(--ui) * 2.6412)); height:max(18px, calc(var(--ui) * 2.6412)); display:flex; align-items:center; justify-content:center; filter:drop-shadow(1px 1px 0 rgba(0,0,0,.5)); }
  .desktopIcon:active .glyph{ transform:translate(1px,1px); }
  .desktopIcon .lbl{ font-size:max(8px, calc(var(--ui) * 0.6773)); color:#fff; text-shadow:1px 1px 0 #000; text-align:center; line-height:1.2; }
  /* The four icon rows are spaced 27% of .desktopScreen's height apart
     (top:4/31/58/85% below), but each icon's glyph+gap+label is a fixed 71px
     tall -- fine at the desktop box's usual ~500px height, but on a
     phone-narrow .cabinet .desktopScreen can be as short as ~156px, where 27%
     is only ~42px and consecutive rows overlap outright (found alongside the
     column-width overlap above, same mobile runtime pass). Below 200px --
     comfortably under any normal width, well above the ~156px phone case --
     shrink the glyph and label together so the whole icon fits inside its own
     row band instead of spilling into the next one. */
  @container desktopScreen (max-height: 200px){
    .desktopIcon{ gap:1px; }
    .desktopIcon .glyph{ font-size:16px; width:24px; height:24px; }
    .desktopIcon .lbl{ font-size:7px; line-height:1.05; }
  }
  #desktopInternetEl{ left:3%; top:4%; }
  #desktopTerminalEl{ left:3%; top:31%; }
  #desktopReadmeEl{ left:3%; top:58%; }
  #desktopSurveyEl{ left:3%; top:85%; }
  #desktopEmailEl{ left:18%; top:4%; }
  #desktopTpsEl{ left:18%; top:31%; }
  #desktopSolitaireEl{ left:18%; top:58%; }
  /* The last free slot in the two-by-four grid. Without a rule of its own an
     icon falls to the screen's top-left corner and lands on top of The
     Internet, so a new program needs a home here as well as markup. */
  #desktopCalcEl{ left:18%; top:85%; }
  /* ---- SYNERGY's desktop on a short screen ----

     The window is 4:3 like a real monitor, and on a narrow screen that makes it
     SHORT: measured at 280x155 on an iPhone 12. The eight icons are laid out
     two columns wide and four rows deep, with the rows at fixed PERCENTAGES of
     the screen -- 4/31/58/85 -- and each icon a fixed ~36-44px tall. Those two
     facts do not survive a small screen together: 15% of 155px is 23px of room
     for the last row, so Survey and Calculator were cut in half, and the
     Company Handbook's two-line label ran into Survey on the way past.

     Two changes, and the second is the one that actually fixes it.

     1. A square window on a phone, which is the owner's call and the right one:
        the monitor's 4:3 shape is decoration and the icons are the point. Same
        width, about 80px more height.

     2. Below 330px of screen height -- which is every phone, and any window
        short enough for the old spacing to fail -- the icons lay out THREE
        ACROSS AND THREE DOWN instead of two across and four down. Nine slots
        for nine icons, Support the Dev included, which is what lets it come in
        off its bottom-right corner where it had nothing to do with the grid.

        Three columns fit because there was never a shortage of width: two
        columns at 3% and 18%, each 15% wide, used the left third of the screen
        and left the other two thirds empty. Spreading into that space buys
        BOTH the row it needed and wider labels, so "Company Handbook" stops
        wrapping to two lines and stops being the icon that collides with the
        one below it.

     Keyed on the screen's own height rather than the viewport's width, because
     the crop is a function of how short the window got -- a 620px window is
     299px tall and cropped just as surely as a 390px one. Only the DEFAULT
     positions move; an icon the player has dragged stays where they put it. */
  @media (max-width: 480px){
    .desktopBox{ aspect-ratio:1/1; }
  }
  @container desktopScreen (max-height: 330px){
    .desktopIcon{ width:29%; }
    #desktopInternetEl  { left:3%;  top:4%; }
    #desktopTerminalEl  { left:34%; top:4%; }
    #desktopReadmeEl    { left:65%; top:4%; }
    #desktopEmailEl     { left:3%;  top:36%; }
    #desktopTpsEl       { left:34%; top:36%; }
    #desktopSolitaireEl { left:65%; top:36%; }
    #desktopSurveyEl    { left:3%;  top:68%; }
    #desktopCalcEl      { left:34%; top:68%; }
    /* Into the grid as the ninth icon. Its corner rule is what it is coming
       out of, so both edges have to be released, not just overridden. */
    .desktopSupportIcon { left:65%; top:68%; right:auto; bottom:auto; }
  }
  .desktopSupportIcon{ right:16px; bottom:16px; }
  .desktopSupportIcon .glyph{ font-size:max(14px, calc(var(--ui) * 2.2349)); width:max(24px, calc(var(--ui) * 3.4539)); height:max(24px, calc(var(--ui) * 3.4539)); }
  .internetBlocked{ display:none; position:absolute; inset:0; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:40px; background:#1a0e0a; }
  .internetBlocked.show{ display:flex; }
  .internetBlocked .iconbtn{ position:absolute; top:12px; right:12px; }
  .ibIcon{ font-size:max(8px, calc(var(--ui) * 3.251)); margin-bottom:10px; }
  .ibTitle{ font-family:'Press Start 2P',monospace; font-size:max(8px, calc(var(--ui) * 1.0837)); color:var(--danger); margin-bottom:16px; letter-spacing:1px; }
  .ibBody{ max-width:80%; color:#ddd; font-size:max(8px, calc(var(--ui) * 0.9482)); line-height:1.5; }
  .ibBody p{ margin:0 0 10px; }

  /* survey */
  .formfield{ width:100%; box-sizing:border-box; background:var(--panel-light); border:2px solid var(--ink); color:var(--text); font-family:'VT323','Courier New',monospace; font-size:max(8px, calc(var(--ui) * 0.9412)); padding:8px 10px; margin-bottom:12px; }
  textarea.formfield{ min-height:70px; resize:vertical; }
  .surveyChoice{ display:block; width:100%; text-align:left; background:var(--panel-light); border:2px solid var(--ink); color:var(--text); font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 0.8235)); padding:10px 12px; margin-bottom:8px; cursor:pointer; box-shadow:3px 3px 0 var(--ink); }
  .surveyChoice:active{ transform:translate(3px,3px); box-shadow:none; }
  .surveyChoice.selected{ outline:2px solid var(--gold); outline-offset:2px; color:var(--gold); }
  .hp-field{ position:absolute; left:-9999px; width:1px; height:1px; opacity:0; }

  /* TPS reports minigame */
  .tpsBox{ max-width:calc(var(--ui) * 37.6435); }
  .solitaireBox{ max-width:calc(var(--ui) * 34.1144); max-height:98%; }
  .tpsPaper{ background:#efe9d6; color:var(--ink); border:2px solid var(--ink); padding:14px 16px; position:relative; }
  .tpsMasthead{ display:flex; justify-content:space-between; align-items:flex-start; gap:12px; font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 0.7059)); border-bottom:2px solid var(--ink); padding-bottom:8px; margin-bottom:6px; }
  .tpsNote{ font-size:max(8px, calc(var(--ui) * 0.7059)); color:#5c5844; margin-top:12px; line-height:1.6; }
  /* Corporate explaining, at length, why it will not tell you what it wants.
     Italic and a shade lighter so it reads as the boilerplate it is -- the
     schedule above it is the part a player actually needs. */
  .tpsNoteJargon{ margin-top:8px; font-style:italic; color:#6b6754; line-height:1.5; }
  .tpsNoteHours{ margin-top:8px; }
  .tpsRow{ display:flex; flex-direction:column; gap:4px; margin-bottom:10px; }
  .tpsRow label{ font-family:'Space Mono',monospace; font-weight:700; font-size:max(8px, calc(var(--ui) * 0.7059)); }
  .tpsRow .formfield{ margin-bottom:0; }
  .tpsPriorityCritical{ color:var(--danger); font-weight:700; }
  .tpsStampArea{ border:2px dashed #8f856b; min-height:70px; position:relative; margin:14px 0; display:flex; align-items:center; justify-content:center; }
  .tpsStamp{ border:4px solid var(--good); color:var(--good); background:none; font-family:'Press Start 2P',monospace; font-size:max(8px, calc(var(--ui) * 0.8824)); padding:8px 12px; transform:rotate(-8deg); opacity:0; letter-spacing:1px; }
  .tpsStamp.show{ opacity:0.85; }
  .tpsStamp.rejected{ border-color:var(--danger); color:var(--danger); }
  .tpsTrayRow{ display:flex; gap:10px; margin-top:14px; flex-wrap:wrap; }
  .tpsTrayRow .supportbtn{ flex:1; min-width:140px; }
  .supportbtn:disabled{ opacity:0.5; cursor:not-allowed; }
  .tpsQuota{ border:2px solid var(--warn); color:var(--warn); padding:10px 12px; text-align:center; font-size:max(8px, calc(var(--ui) * 0.8235)); width:100%; }
  .tpsProblems{ color:var(--danger); font-size:max(8px, calc(var(--ui) * 0.8235)); margin-top:12px; line-height:1.5; }
  .tpsProblems ul{ margin:6px 0; padding-left:18px; }

  /* terminal */
  .terminalOutput{ background:#0c0f0a; border:2px solid var(--ink); color:#8fef6a; font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 0.7647)); padding:10px; height:220px; overflow-y:auto; white-space:pre-wrap; margin-bottom:10px; }
  /* Full screen. Same shape as Solitaire's maximize, which is the only other
     window in the game that does this: the box fills the cabinet, the body
     takes the slack, and the scrollback -- normally a fixed 220px -- grows to
     whatever is left above the input row. The input row keeps its natural
     height so it never gets squeezed off the bottom. */
  .panelBox.termMaximized{ position:absolute; inset:0; width:auto; max-width:none; height:auto; max-height:none; }
  .panelBox.termMaximized .panelBody{ flex:1; min-height:0; display:flex; flex-direction:column; }
  .panelBox.termMaximized .terminalOutput{ flex:1; height:auto; min-height:0; }
  /* The close and full-screen buttons hang OUTSIDE the panel by 14px, which is
     off-screen once the panel is the screen. Brought inside, and only then. */
  .panelBox.termMaximized .panelClose{ top:6px; right:6px; }
  .panelBox.termMaximized .panelMaximize{ top:6px; right:44px; }
  .terminalInputRow{ display:flex; align-items:center; gap:6px; background:#0c0f0a; border:2px solid var(--ink); padding:8px 10px; }
  .terminalPrompt{ color:#8fef6a; font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 0.7647)); }
  .terminalInput{ flex:1; min-width:0; background:transparent; border:none; color:#8fef6a; font-family:'Space Mono',monospace; font-size:max(8px, calc(var(--ui) * 0.7647)); outline:none; }
  .terminalInput:focus-visible{ outline:2px solid var(--gold); outline-offset:-2px; }

  /* quick \-bar */
  .quickBar{ position:absolute; left:14px; right:14px; bottom:14px; z-index:9999; background:rgba(8,8,6,0.94); border:2px solid var(--ink); box-shadow:4px 4px 0 rgba(0,0,0,0.5); display:none; flex-direction:column; opacity:0; transition:opacity 0.25s; }
  .quickBar.show{ display:flex; opacity:1; }
  .quickBar.fading{ opacity:0; }
  .quickBarLog{ max-height:180px; overflow-y:auto; padding:8px 10px 0; color:#8fef6a; font-family:'Space Mono',monospace; font-size:13px; white-space:pre-wrap; }
  .quickBarLog:empty{ padding:0; }

  /* A player who has told their OS they get motion sick or distracted by
     animation is asking every app to respect that, not just this one. 43
     separate animations (the fluorescent flicker, the toast slide, the
     background drift, every hover/press transition) is too many to safely
     hunt down one at a time without missing one, so this is a blanket kill
     switch rather than a rule-by-rule opt-out: every animation and transition
     collapses to effectively instant, its end state applied immediately.
     forwards fill-mode animations (the toast fade, for one) still reach their
     final state, just without the motion getting there. */
  @media (prefers-reduced-motion: reduce){
    *, *::before, *::after{
      animation-duration:0.001ms !important;
      animation-iteration-count:1 !important;
      transition-duration:0.001ms !important;
      scroll-behavior:auto !important;
    }
  }
