/* ============================================================
   INKVILLE — base.css
   the design system: paper, ink, halftone, screens, type, buttons, cards, modals

   Part of the game.css split. Loaded as <link> tags from index.html in
   THIS ORDER — the cascade depends on it. Do not reorder the links.
   ============================================================ */
/* ============================================================
   INKVILLE — design system
   Paper, ink, halftone, film grain, rubberhose bounce.
   ============================================================ */
/* ⭐⭐⭐ v0.891 — THE TWO PIXEL FACES SHIP WITH THE GAME. Odaroo played at an airport with no
   signal and the game would not come up: index.html linked fonts.googleapis.com, a cross-origin
   stylesheet the service worker never caches, and on a wifi that is "connected" with no internet
   behind it that request hangs the boot for as long as the browser cares to wait. The files live
   in fonts/ (OFL licences beside them), the room serves them, sw.js precaches them, and nothing in
   the page reaches outside its own origin any more. ⚠ No ?v= on the url: sw.js matches fonts with
   ignoreSearch, and the file is what it is - a font does not change between builds. */
@font-face{font-family:'Press Start 2P'; font-style:normal; font-weight:400; font-display:swap;
  src:url(../fonts/PressStart2P-Regular.woff2) format('woff2');}
@font-face{font-family:'DotGothic16'; font-style:normal; font-weight:400; font-display:swap;
  src:url(../fonts/DotGothic16-Regular.woff2) format('woff2');}
:root{
  --paper:#f2e5c9; --paper2:#e9d8b2; --paper3:#dfc99a;
  --ink:#1b1510; --ink2:#2e251c;
  --red:#c93a22; --red2:#a52c18;
  --gold:#d9a232; --gold2:#b07f1d;
  --teal:#37756d; --blue:#3b5e8c;
  --cream:#fdf6e3;
  --fontD:'Press Start 2P', 'Courier New', monospace;
  --fontT:'Press Start 2P', 'Courier New', monospace;
  --fontJ:'DotGothic16', 'Press Start 2P', monospace;   /* retro DOT-pixel Japanese to match the English */
  --pop:cubic-bezier(.34,1.56,.64,1);
  /* ⭐⭐ v0.385 — ONE RULE FOR EVERY BROWSER: the game is exactly as tall as the window it is given.
     --appvh is the visible height, written by vpFit() in game.js straight from
     window.visualViewport (falling back to innerHeight), with NOTHING subtracted. 100dvh is only
     the pre-script fallback. The browser-specific 62/84px shave that used to live here is gone —
     it was the black band under the game on Samsung Internet, and it never existed in Chrome. */
  --appvh:100dvh;
  /* The only bottom breathing room, and it is a real standard reported by the DEVICE, applied
     identically in every browser: the phone's own gesture/home bar. Never a user-agent test. */
  /* ⭐⭐⭐ v0.855 — AND IT IS LATCHED IN JS, BECAUSE ON ANDROID THIS INSET **IS** THE NAVIGATION
     BAR AND IT COMES AND GOES. `--sab-src` is what the DEVICE says right now; `--sab` is what we
     have decided to believe, written inline on <html> by inkSabWatch() in js/core.js (the largest
     inset seen, held until the WIDTH moves). Splitting them is what stops a feedback loop: the
     probe reads --sab-src, the latch writes --sab, and the cap below stays the stylesheet's own
     business rather than being re-typed in JS. If the script never runs, --sab is --sab-src and
     the behaviour is exactly what it was before. See the note in js/core.js. */
  --sab-src:min(env(safe-area-inset-bottom,0px),16px);
  --sab:var(--sab-src);
  /* the top half of the same thing. Outside standalone/fullscreen the browser's own address bar
     already keeps the page off the status bar, so this is 0 and nothing moves either way. */
  --sat-src:0px;
  --sat:var(--sat-src);
  /* v0.381 — the boot screen takes whichever is bigger: the mis-scale correction (--gs) or the
     big-screen boot scale (--bgs). Folded phone = 1 and nothing moves. */
  --ibs:max(var(--gs,1), var(--bgs,1));
  /* ⭐⭐⭐ v0.567 — THE USERNAME IS ONE SIZE, EVERYWHERE. (Odaroo, 2026-08-04: "the username should be
     very personalized and very large — the size it is in online play should be the size it is on
     every screen.") The film reel in a match (.vsnm-c, css/hub.css) was already the biggest and
     best-looking printing of a name in the game, so IT is the standard and every other screen is
     matched to it rather than the other way round.
     ⚠ THIS IS THE ONE PLACE THE SIZE LIVES. .vsnm-c, .hud-name (the home/menu bar) and the profile
     hero all read it. A screen that prints the player's name and does NOT read this var is a bug,
     not a style choice — that is exactly how the home bar ended up at 10px while the match reel
     was at 13px on the same phone.
     ⚠ It is a BARE length, not scaled: each site multiplies by its own --gs. */
  /* ⭐⭐⭐ v0.578 — 13px → 17px, AND THE ROOM FOR IT WAS ALREADY PAID FOR. (Odaroo, 2026-08-04:
     "the font size should be LARGER since levels were removed — go big with the username, and the
     same size for the online opponent's name.")
     v0.567 deleted the level chip that used to ride inside this strip ahead of the name, and the
     strip kept the width the chip had been using. That is the budget this spends. The 12-character
     worst case ("WWWWWWWWWWWW", the widest string the name cap can produce) is what .vsnm-c's 48.5%
     ceiling was measured against, and vsNameFit steps BOTH cells down together when a real pair of
     names will not fit — so a name too long for 17px is printed a point smaller rather than clipped.
     ⚠ ONE NUMBER STILL, for both sides of the reel: mine and the rival's are the same element and
     always were. "The same large font for opponent names" is a property of not special-casing them. */
  /* ⭐⭐⭐ v0.581 — 17px → 20px. He has now asked THREE times for this name to be bigger, so the
     step is a real one rather than another cautious point. What makes it safe is that --nmsz is
     the size a name STARTS at, never the size it is forced to be: vsNameFit (js/online.js) and
     hudNameFit (js/ui.js) both step DOWN half a point at a time until the name fits whole. So a
     short name — which is most names — gets the full 20px, and "ODAROOOOOO" lands wherever it
     lands instead of being clipped. Raising the ceiling cannot clip anything; it can only stop
     shrinking things that never needed to shrink.
     ⚠⚠ 18px IS A MEASURED CEILING, NOT A TASTE. The name strip is the top HUD, and the HUD's height
     is what Tetr-Ink and Ink Panik size their playfields against. Run against the real pre-flight:
     18px is clean, 19px puts the Tetr-Ink well under the HUD by 29x4 pixels and 20px by 35x6. So
     going higher is not "bigger text", it is text drawn over the board. If this number is ever to
     rise again, the two playfields have to reserve the room FIRST (games.css:85 shows the shape —
     #scr-hell already pads itself by --nmsz) and the pre-flight has to prove it. */
  /* ⭐⭐⭐ v0.583 — 18px → 24px, AND THE CEILING THE v0.581 NOTE DESCRIBED IS GONE RATHER THAN
     IGNORED. That note is directly above and it is the reason this took three builds: 18px was
     measured, 19px drew the Tetr-Ink well under the HUD by 29x4 and 20px by 35x6, and it named the
     precondition — "the two playfields have to reserve the room FIRST (games.css:85 shows the
     shape) and the pre-flight has to prove it".
     Both halves of that are now done: `#tetWrap, #horrorWrap` at the end of css/games.css inset
     themselves by this very variable, exactly as #scr-hell already did, so the boards move down when
     the name grows instead of being overwritten by it. The collision cannot recur by construction —
     and the two wells give up height for it, which is the honest cost of a bigger name.
     ⚠ RAISING THIS IS STILL NOT FREE. It is now bounded by the two wells' HEIGHT rather than by a
     collision, and a well that has given up too much stops being playable long before it looks
     wrong. Re-run the pre-flight at BOTH folds before touching it again.
     ⚠ AND IT REMAINS THE SIZE A NAME STARTS AT, never the size it is forced to be — vsNameFit
     (js/online.js) and hudNameFit (js/ui.js) still step DOWN half a point at a time until a long
     name fits whole. A short name gets the full 24px; "ODAROOOOOO" lands wherever it lands. */
  /* ⭐⭐⭐ v0.588 — 24px → THE CUPS-AND-INK SIZE, AND THIS REVERSES v0.578/v0.581/v0.583.
     (Odaroo, 2026-08-04: "the username is too big/grandiose. Make it ONE SIZE that works for ALL
     name lengths. Use the SAME font and size as the cups count and the ink number already on the
     HUD bar — same spacing, same line height. It should look like it is PART OF the same bar.")
     Everything above is the history of this number being raised on request, three builds running,
     from 13 to 24. It is now pinned to something that is not a matter of taste at all: the figures
     it sits beside. `#hudHome .chip` is 14px, and 11px under the ≤430px block (both in this file,
     a few hundred lines down) — so those are the two numbers, and the breakpoint moves to match
     THEIRS rather than keeping the reel's old 640px one.
     ⚠⚠ "ONE SIZE THAT WORKS FOR ALL NAME LENGTHS" IS NOW LITERALLY TRUE, and that is the real win
     here rather than the smaller look. vsNameFit and hudNameFit step DOWN from this number until a
     name fits, so at 24px a long name was printed smaller than a short one and the same player's
     name changed size between screens. At the chip size even the 14-character cap fits whole on the
     folded phone, so the loops never fire and every name is the same size — which is the ask.
     ⚠ RAISING THIS AGAIN IS NOT A STYLE CHOICE. The v0.581/v0.583 notes above are still live: this
     var sets the top HUD's height, and Tetr-Ink and Ink Panik size their wells against it. Lowering
     it only ever gives those wells room back. */
  /* ⭐⭐⭐ v0.613 — 11px → 17px. (Odaroo, 2026-08-05: "the username should be much bigger — not too
     big like the connecting/connected text, but big enough that it's important and it appears
     first.") The connecting banner is 0.40 of the HUD bar (21.6px folded); this is 0.31 of it, which
     is the gap he described: clearly the first thing read on the strip, clearly not the banner.
     ⚠⚠ IT IS ALSO NOW BIGGER THAN THE NUMBERS BESIDE IT, WHICH IS THE ACTUAL POINT. The chips print
     at 11px, and this var was ALSO 11px — so the player's own name was set at exactly the weight of
     a cup count and read as one more figure on the bar rather than as the heading. 17 against 11 is
     what makes it the heading.
     ⚠ RAISING THIS IS SAFE BY CONSTRUCTION AND THAT IS DELIBERATE DESIGN, not luck: --nmsz is the
     size a name STARTS at, never the size it is forced to be. hudNameFit (js/ui.js) and vsNameFit
     (js/online.js) both step DOWN half a point at a time until the name fits whole, so a long name
     is printed smaller rather than clipped. Measured: the folded home bar gives the name 133px, and
     a 6-character name takes ~102px of it at 17px — the full size. The 14-character test save
     ("ODAROOOOOOOOOO") steps back down to ~9.5px exactly as it did before, which is the loop doing
     its job, not this change failing.
     ⚠ The wells pay for it and they were built to: #tetWrap/#horrorWrap/#scr-hell reserve
     `--nmsz * 1.6 + pad` (css/games.css), so the strip's room grows with the name automatically and
     the v0.463 fault — a HUD sitting on the bullet field — cannot come back. The pre-flight proves
     the playfields still pass at both folds. */
  --nmsz:17px;
  /* ⭐⭐⭐ v0.701 — AND THE IN-GAME REEL IS SMALLER THAN THE MENU'S. (Odaroo, 2026-08-06, on a
     Disc-Ink screenshot: "the film reel username bars are too big — they're taking up too much
     space and not leaving room for the game content above.")
     ⚠⚠ THIS DELIBERATELY PARTS COMPANY WITH v0.630's "one universal username size", and only on the
     game screens. That rule was about the name not visibly CHANGING SIZE as you walked from the menu
     into a match, and it was the right rule for the menu, the profile and the album — surfaces whose
     whole job is to show you who you are. A playfield's job is the playfield: the reel there is a
     label on a corner, and at 17px it was eating the room the board needs. The two portraits still
     hang off this strip's measured height (vsNamePlace), so shrinking the face hands that space back
     to every game at once.
     ⚠ IT IS A SEPARATE VAR RATHER THAN AN OVERRIDDEN --nmsz SO THE MENU CANNOT BE CHANGED BY
     ACCIDENT. `.vsnm` re-points --nmsz to this (css/hub.css) for everything inside the reel, which
     is why vsNameFit must read the var off the BAR and not off :root — see the note there. */
  --nmsz-game:13px;
  /* ⭐⭐⭐ v0.640 — THE ONE INKLET PORTRAIT SIZE, EVERYWHERE. (Odaroo, 2026-08-05: "the usernames and
     inklet avatars in online/VS mode should be the SAME size as on the main menu. Universal size
     everywhere.")
     The main menu's lens (.hud-av.hud-av-lg) was 69px, and 78px on a folded phone; the VS corners
     were a hard-coded 58px in js/online.js (INK_PORT_W). So your own face visibly SHRANK the moment
     you walked from the menu into a game, and the rival's shrank with it — two different sizes for
     the same drawing, with nothing on either side explaining the difference.
     ⚠⚠ THE NUMBER LIVES HERE AND NOWHERE ELSE. The menu lens reads it, the Ink-Put health row reads
     it, and inkPortW() (js/online.js) reads it back out for the corners it has to force inline. The
     same drift rule as --nmsz: never re-type it in JS, ask the var. */
  --inkav:69px;
  /* ⭐⭐⭐ v0.641 — THE SHOWCASE SIZE, WHICH IS A DIFFERENT QUESTION FROM THE CORNER SIZE.
     (Odaroo, 2026-08-05: "remove the camera icon. Instead show a BIGGER frame and a BIGGER inklet
     featured on the screen. This is the showcase position.")
     The camera BODY around the lens (.hud-cam, css/menu.css) is gone, and the room it was taking
     goes to the portrait: on the home bar and on the profile hero the lens IS the frame now, and it
     is the biggest thing on the screen rather than a 69px disc inside a 75px box.
     ⚠⚠ IT IS A SECOND VAR AND THAT IS MEASURED, NOT TIMID. Raising --inkav itself was tried first,
     because v0.640 made that one number the size of the menu lens AND both VS corners. The
     pre-flight is unambiguous about what that costs in a GAME: at 92px the Disc-Ink table fell to
     60% of the width and 56% of the height it has (the "fill the screen" rule), the rink's playfield
     came out under its own HUD by 10×95px, and both usernames on the Disc-Ink reel were shrunk to
     15px by a portrait eating the strip. A corner label and a showcase are not the same job, and one
     number cannot be right for both.
     ⚠ WHAT *IS* UNIVERSAL IS THE POSITION — --hudtop below, which every screen now shares. The
     corner portrait stays --inkav (69/78, v0.640's number) so no playfield loses ground. */
  --inkav-lg:92px;
  /* ⭐⭐⭐ v0.641 — THE ONE TOP GAP FOR EVERY CORNER-ANCHORED FILM REEL, AND IT IS 4× WHAT IT WAS.
     (Odaroo, 2026-08-05: "keep the gap at the top of the film reel but make it FOUR TIMES bigger —
     and the connected reel shifts down by the same amount. Whatever we set on the home screen is
     the standard position on ALL screens.")
     The gap was written as a bare `4px * var(--gs,1)` in six places — .vsnm.vsnm-top (css/hub.css)
     and four #crLineBadge rules (css/crown.css) — each one a copy with a comment telling the next
     person to keep the others in step. That is precisely the drift this project keeps paying for,
     so the number is named once here and every one of those rules asks for it.
     ⚠⚠ IT IS *ADDED TO* --satnm, NEVER INSTEAD OF IT. The camera-cutout inset is a separate
     question (see the v0.578/v0.581 notes at the bottom of this file) and it still applies.
     ⚠⚠ THE HOME BAR ADDS IT TO --hudlift, NOT IN PLACE OF IT. hudLift() measures how far the lens
     overhangs the strip; that reservation is what stops the inklet's head being cropped and it grows
     with --inkav. This var is the AIR ABOVE the head — the thing Odaroo can actually see.
     ⚠ Anything new pinned to the top edge of a game screen must add this, or it will sit on the
     reel that everything else just moved down from. */
  /* ⭐⭐⭐ v0.717 — DOUBLED, 16px → 32px. (Odaroo, 2026-08-06: "double that spacing … for the time
     and for the name of the inklings … across all boards, across all games, solo and online.")
     Nothing else changed: because every corner-anchored reel, both usernames, the match clock and
     the two playfields that reserve headroom (#tetWrap/#horrorWrap) all ASK for this var rather
     than restating 16, moving it here moves all of them together. That is the whole point of the
     v0.641 note above — do not re-introduce a second copy of this number anywhere. */
  --hudtop:32px;
  /* ⚠⚠ THE GROWTH SINCE THE GAP WAS 16px — for playfields whose furniture is a LADDER OF HARD PIXEL
     TOPS rather than a single measured box. The two rhythm stages are the only ones: Dance Dance Ink
     and Ink-Tar Hero each hang a title, a star row, a combo, an HP bar and the lane field off their
     own hard `top:` numbers, all tuned against each other. Nudging only the field would have driven
     it into the star row above it, so the whole STAGE moves down by this and the tuning inside is
     left exactly as it was.
     ⚠ It is not a licence to hard-code: anything NEW pins to --hudtop itself. This exists only to
     carry furniture that predates the var. */
  --hudtop-extra:16px;
  /* ⭐⭐⭐ v0.567 — THE ONE SPROCKET RECIPE, and it is CENTRED AND MIRRORED.
     Odaroo asked for the Crown film strip everywhere and for the holes to be "perfectly aligned,
     mirrored, centered — no sloppy placement".
     ⚠⚠ THE OLD TILE WAS NOT SYMMETRIC AND SO COULD NOT BE CENTRED. It was `#fff 0 3px, transparent
     3px 7px` stretched to `background-size:100%`, which starts the run of holes hard against the
     LEFT edge — so a strip ended on a hole at one end and on a gap at the other, and the pattern
     had no mirror line at all. The tile below puts the hole in the MIDDLE of its own 7px period
     (gap 2 · hole 3 · gap 2), and the layer is `repeat-x` positioned at `center`, so the run is
     symmetric about the middle of the box at any width. Top row and bottom row use the identical
     layer, so they mirror each other exactly too.
     ⚠ THE VERTICAL INSET IS EQUAL TOP AND BOTTOM (--film-in), which is what makes the two rows
     mirror. Do not nudge one of them. */
  --film-hole:#ffffff;
  --film-per:7px;    /* one sprocket period */
  --film-w:3px;      /* the hole itself */
  --film-h:3px;      /* how tall the row of holes is */
  --film-in:1.5px;   /* the row's inset from the edge — the SAME number top and bottom */
  /* ⭐⭐⭐ v0.734 — THE **BIG** GAUGE, NAMED ONCE. (Odaroo, 2026-08-06: "the same type of white
     pixels, the same size — I've noticed some of them are changing. Investigate all of them and
     have a uniform film reel size that has the same hole sizes too.")
     There are two sizes of film in this game and only two: the NAME PLATES (the four vars above —
     a username chip is a small piece of film) and the STATUS BARS — the home/HUD reel, the
     CONNECTING banner, the notification strip and the credits reel. All four of those are the same
     bar and must be the same film, and until now all four wrote their own numbers:
       .hudreel      14px period · 6px hole · 7px band · #fff at .95
       #crLineBadge  14px period · 6px hole · 7px band · #fff
       #toast        14px period · **8px** hole · 7px band · **#f2ead6**   ← the one he spotted
       #ledgReel     28px period · 9px hole · 10px band · DARK holes on a grey bar
     So the big gauge is named here, derived from the small one so the two can never drift, and
     every bar pulls these three instead of doing its own arithmetic.
     ⚠ These are plain NUMBERS, not a baked gradient. The trap described further down (a
     `--film-tile` on :root freezes root's values into every child) only bites when the whole
     background-image is put in a var; a length substituted from :root is exactly what is wanted
     here, because there IS only one gauge. */
  --film2-per:calc(var(--film-per) * 2);   /* 14px — one sprocket period on a status bar */
  --film2-w:calc(var(--film-w) * 2);       /* 6px  — the hole itself */
  --film2-h:7px;                           /* the row of holes, top and bottom */
  /* ⭐⭐⭐ v0.611 — THE HOME REEL'S THICKNESS, AND IT IS ARITHMETIC AGAIN BECAUSE THE MEASUREMENT
     WENT STALE. (Odaroo, 2026-08-05: "the height of the reel must match the game menus, and the
     position too — sometimes it's bigger, sometimes it's smaller.")
     v0.591 hard-coded 29px here after measuring #hudHome, with a standing note saying to re-measure
     it if the chips ever changed. They did — v0.565 put the level chip in the reel — and nobody
     came back to this number. Measured today the home bar is **54px folded and 57px unfolded**,
     against the 29 written here: the constant had been wrong by almost double for forty builds, and
     every strip that trusted it was half the thickness of the bar it claimed to match.
     ⚠⚠ SO IT IS DERIVED NOW, NOT MEASURED. The bar's height is entirely decided by four numbers in
     the `.topbar` / `#hudHome .chip` rules further down this file — the chip's height plus the
     bar's own padding plus its two 3px leaders — so those four are named here and BOTH the bar and
     anything matching it expand the same expression. A copied constant drifts the moment one of
     them moves (that is precisely what happened); shared arithmetic cannot.
     ⚠ `--hudchip-h` IS CONSUMED BY `#hudHome .chip` ITSELF (see the rule below), which is what
     makes this a single source of truth rather than a second copy of the same guess. It steps to
     25px inside the ≤430px block, so this whole expression follows the fold on its own:
       28 + 10 + 13 + 3 + 3 = 57 unfolded   ·   25 + 10 + 13 + 3 + 3 = 54 folded   — both verified. */
  --hudchip-h:28px;   /* the cups/ink/reel chip inside #hudHome — the bar's height comes from this */
  /* ⭐⭐⭐ v0.613 — THE BAR'S PADDING IS SYMMETRIC NOW, AND THE TOTAL IS UNCHANGED. (Odaroo,
     2026-08-05: "the username is not properly centered.")
     `.topbar` was `padding:10px 12px 13px` — three more pixels below than above — so EVERYTHING on
     the strip (the name, every chip, the whole row) sat **1.5px high in its own bar**, measured. It
     is a small number and it is on the one bar the player looks at most, which is the worst place
     for a small number to be wrong: nothing looks broken, it just never looks quite right.
     ⚠⚠ 11.5 + 11.5 = 23 = 10 + 13, SO THE BAR'S HEIGHT DOES NOT MOVE. That matters more than it
     looks: --filmbar-h is this sum plus the chip and the leaders, and the connecting banner is
     pinned to --filmbar-h (v0.611). Balancing the padding by ADDING a pixel would have silently
     retuned the banner and the reel together. The row moves; the bar does not.
     ⚠ `.topbar` reads these vars rather than restating them, or this expression would be describing
     a padding the bar does not actually have — which is the same class of fault as the stale 29px
     --filmbar-h these vars were introduced to kill. */
  --hudbar-pt:11.5px;   /* .topbar padding-top    — equal to -pb ON PURPOSE, see above */
  --hudbar-pb:11.5px;   /* .topbar padding-bottom — the two must stay equal */
  --hudbar-bd:3px;    /* the ink leader above and below it */
  --filmbar-h:calc(var(--hudchip-h) + var(--hudbar-pt) + var(--hudbar-pb) + var(--hudbar-bd) * 2);
  /* ⚠⚠⚠ AND THE ARITHMETIC IS NOT ALLOWED TO LIVE UP HERE. The obvious tidy-up is a
     `--film-tile: linear-gradient(… var(--film-per) …)` next to these, so every strip could pull the
     whole image in on one line. IT SILENTLY DOES THE WRONG THING and it was written and measured
     that way first: a custom property's var()s are substituted where the property is DECLARED, so a
     --film-tile declared on :root bakes in ROOT's 7px and children inherit the finished value. A box
     that overrides --film-per to 11px would keep --film-per:11px in its computed style and still
     paint 7px holes — a discrepancy with no error and nothing on screen to point at it.
     So the four numbers above are the only shared thing, and each strip expands them itself. */
}
/* ⚠ 640px is the SAME breakpoint the match reel has always used, so the folded Fold reads 13px and
   the unfolded one 16px on every screen at once. */
/* ⚠⚠ v0.588 — THE BREAKPOINT IS THE CHIPS' BREAKPOINT NOW, NOT THE REEL'S OLD 640px. The name is
   pinned to the cups/ink figures beside it, and those step at 430px (`#hudHome .chip` is 14px, and
   11px inside the `max-width:430px` block further down this file). Keeping 640 here would have put
   the name at 11px next to a 14px cup count on every screen between 431 and 639 — the exact
   "looks like two different bars" fault this change is fixing, just moved to a width nobody tests. */
/* v0.613 — 14px → 19px, the same step as the folded value above and for the same reasons. */
@media (min-width:431px){ :root{ --nmsz:19px; --nmsz-game:15px; } }
/* ⭐⭐ THE SHARED STRIP. Anything that wants to BE a piece of film wears .inkfilm-holes and gets the
   centred, mirrored sprockets at whatever scale its own four vars say. It sets background-IMAGE,
   -size, -position and -repeat and nothing else, so it composes with any background-COLOR.
   ⚠⚠ NEVER write a `background:` shorthand on a box wearing this — the shorthand resets
   background-image and silently wipes the holes. That trap has cost this project two builds
   (v0.473, v0.536). Set `background-color:` on its own. */
/* ⭐⭐⭐ v0.712 — `.vsmid` JOINS THE LIST. (Odaroo, 2026-08-06: "put the time in the SAME frame, the
   same size as a film reel behind the username, behind the enemy inkling.") The clock is a third
   piece of film on the header row now, so it must take its sprockets from the same recipe as the
   two name cells — a hand-copied second edition is exactly how the two strips would drift apart the
   next time --film-per or --film-h moves. This reverses v0.705's "no reel for the timer" and the
   reversal is deliberate; see the note on .vsmid in css/hub.css. */
.inkfilm-holes, .inkfilm, .vsnm-c, .vsmid, #toast.inkfilm-sign{
  background-image:linear-gradient(to right,
     transparent 0 calc((var(--film-per) - var(--film-w))/2 * var(--gs,1)),
     var(--film-hole) calc((var(--film-per) - var(--film-w))/2 * var(--gs,1)) calc((var(--film-per) + var(--film-w))/2 * var(--gs,1)),
     transparent calc((var(--film-per) + var(--film-w))/2 * var(--gs,1)) calc(var(--film-per) * var(--gs,1))),
    linear-gradient(to right,
     transparent 0 calc((var(--film-per) - var(--film-w))/2 * var(--gs,1)),
     var(--film-hole) calc((var(--film-per) - var(--film-w))/2 * var(--gs,1)) calc((var(--film-per) + var(--film-w))/2 * var(--gs,1)),
     transparent calc((var(--film-per) + var(--film-w))/2 * var(--gs,1)) calc(var(--film-per) * var(--gs,1)));
  background-size:calc(var(--film-per) * var(--gs,1)) calc(var(--film-h) * var(--gs,1)), calc(var(--film-per) * var(--gs,1)) calc(var(--film-h) * var(--gs,1));
  background-position:center calc(var(--film-in) * var(--gs,1)), center calc(100% - var(--film-in) * var(--gs,1));
  background-repeat:repeat-x, repeat-x;}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
/* ⭐⭐⭐ v0.856 — THE PAGE IS PINNED. IT CANNOT BE SCROLLED, BY ANYONE, EVER.
   `overflow:hidden` was on `body` alone and relied on the propagation rule (a body value only
   reaches the viewport while `html` is `visible`) — which held, but left `html` itself as the
   scrolling element with `height:var(--appvh)`. That matters on exactly one device: --appvh is
   latched to the TALLER height (v0.854, deliberately), so in Chrome's Android fullscreen the
   document is as tall as the screen WITHOUT its navigation bar while the visible area is shorter
   whenever that bar is showing. A page taller than its visible area is a page with somewhere to
   go, and Chrome moves it around a touch — WITHOUT laying anything out.
   ⚠⚠⚠ THAT IS THE ONE KIND OF MOTION EVERY INSTRUMENT IN tools/ IS BLIND TO. probe-all-screens,
   test-idle-cost, test-home-still, probe-slide-churn and probe-vp-ramp all count LAYOUT, and a
   scroll costs none: a build being scrolled around measures 0-1 layout/s and reports perfectly
   clean. Stated here it is simply not possible, whatever any unit resolves to.
   ⚠ Inner scrollers are untouched — this is the VIEWPORT, and .ledg-body / .wardgrid / .pf-favlist
   keep their own overflow-y:auto. */
html,body{height:100%; height:var(--appvh,100%); overscroll-behavior:none; overflow:hidden;}
body{
  background:#0e0b08;
  background-image:radial-gradient(#ffffff09 1.5px, transparent 2px);
  background-size:26px 26px;
  font-family:var(--fontT);
  color:var(--ink);
  overflow:hidden;
  user-select:none;-webkit-user-select:none;
}
#app{
  position:relative;
  width:100%; max-width:100vw; height:100dvh; height:var(--appvh,100dvh); margin:0 auto;
  background:var(--paper);
  overflow:hidden;
  box-shadow:0 0 0 4px var(--ink), 0 0 90px #000c;
}
/* FULL BLEED — the game OWNS the whole screen on every device (Fold 6 unfolded, desktop Chrome, all of it).
   The old 600px centered frame (letterbox) is RETIRED — no dead rails left and right. */
/* "+N" that pops off the ink counter while it counts up */
.plusFloat{position:fixed; z-index:210; transform:translateX(-50%); font-family:var(--fontD); font-size:15px; color:var(--gold2);
  text-shadow:2px 2px 0 var(--ink); pointer-events:none; animation:plusUp 1.45s ease-out forwards;}
@keyframes plusUp{0%{opacity:0; transform:translateX(-50%) translateY(6px) scale(.6)}18%{opacity:1; transform:translateX(-50%) translateY(0) scale(1.15)}70%{opacity:1}100%{opacity:0; transform:translateX(-50%) translateY(-16px) scale(1)}}
/* the skater drops through the broken ice */
#rinkP.rfall{animation:rfallA .9s ease-in forwards;}
@keyframes rfallA{0%{transform:none; opacity:1}30%{transform:translateY(8%) rotate(10deg)}100%{transform:translateY(150%) rotate(38deg) scale(.55); opacity:0}}
/* the Lv chip + billing-ladder modal */
/* ⚠⚠ v0.567 — .lvchip, .xpbar/.xpfill/.xptxt and the .skillrow ladder are DELETED with the player
   level and XP. Nothing in the game has a level, an EXP bar or a skill unlock any more. */
/* CHAINED MODES — lock + chains + break animation */
.chainlock{position:absolute; inset:0; z-index:6; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; background:#1b1510aa; pointer-events:none; overflow:hidden;}
/* ⭐ v0.705 — the chain is VECTOR, so it must never be pixel-snapped or stretched. `slice` in the
   markup keeps a link circular on a non-square tile; the drop-shadow gives the run its weight. */
.cl-chain{position:absolute; inset:0; width:100%; height:100%; transition:transform .35s ease;
  shape-rendering:geometricPrecision; filter:drop-shadow(1px 2px 1.5px #0009);}
.chainlock.ready .cl-chain{transform:scale(1.03,.9);}   /* about to break — the chains pull TIGHT */
.cl-lock{width:calc(46px * var(--gs,1)); height:calc(54px * var(--gs,1)); filter:drop-shadow(2px 3px 2px #000a); position:relative; z-index:2;}
.cl-lock svg{width:100%; height:100%;}
.cl-need{font-family:var(--fontD); font-size:9px; color:var(--cream); text-shadow:2px 2px 0 var(--ink); z-index:2; letter-spacing:.5px;}
.chainlock.ready{animation:clGlow 1.1s ease-in-out infinite;}
.chainlock.ready .cl-lock{animation:clShake .5s ease-in-out infinite;}
@keyframes clGlow{0%,100%{background:#1b1510aa}50%{background:#8a6a1a55; box-shadow:inset 0 0 28px #f4c542aa}}
@keyframes clShake{0%,100%{transform:none}25%{transform:translateX(-2px) rotate(-3deg)}75%{transform:translateX(2px) rotate(3deg)}}
.cl-zap{position:absolute; top:5%; right:7%; width:16px; height:24px; z-index:3; animation:clZap .7s steps(2) infinite; filter:drop-shadow(0 0 7px #f4c542);}
.cl-zap svg{width:100%; height:100%;}
@keyframes clZap{0%{opacity:.1}50%{opacity:1}}
/* ⭐⭐⭐ v0.705 — THE UNCHAINING. The run used to simply scale up and fade, which read as the whole
   overlay zooming rather than as anything BREAKING. Now it happens in three beats:
     1. the chains SNAP TAUT (a 90ms yank), which is what sells the break that follows;
     2. every link flies off on its OWN arc — each <g class="clnk"> carries a pre-rolled
        --dx/--dy/--sp/--dl from chainRun(), so twenty-two links scatter and tumble independently
        instead of moving as one sheet;
     3. the padlock's shackle springs open, then the body drops out of frame, spinning.
   ⚠ The links animate `translate`/`rotate`/`scale` as INDEPENDENT properties, not the `transform`
   shorthand — the link's base position is an SVG `transform` ATTRIBUTE, and a CSS `transform`
   would override it, teleporting every link to the middle of the tile before it flew away.
   ⚠ Same trap as the vault breath: `animation` is a shorthand, so the run's own animation and the
   per-link one must live on different elements (.clrun vs .clnk). */
.chainlock.breaking .cl-chain{animation:clYank .62s ease-out forwards;}
.chainlock.breaking .clnk{animation:clFly .74s cubic-bezier(.28,.5,.5,1) var(--dl,0s) forwards; transform-box:fill-box; transform-origin:center;}
.chainlock.breaking .cl-lock{animation:clDrop .78s cubic-bezier(.3,.05,.7,1) .06s forwards;}
.chainlock.breaking .blk-sh{animation:clShack .5s cubic-bezier(.3,1.6,.5,1) forwards; transform-box:fill-box; transform-origin:80% 90%;}
.chainlock.breaking .cl-need,.chainlock.breaking .cl-zap{animation:clPuff .3s ease-in forwards;}
.chainlock.breaking{animation:clFlash .74s ease-out forwards;}
@keyframes clYank{0%{transform:none}14%{transform:scale(1.06,.9)}22%{transform:scale(1.01,1.04)}100%{transform:none}}
@keyframes clFly{
  0%{translate:0 0; rotate:0deg; opacity:1}
  16%{translate:calc(var(--dx) * -.06) calc(var(--dy) * -.10); rotate:calc(var(--sp) * .04); opacity:1}
  100%{translate:var(--dx) var(--dy); rotate:var(--sp); scale:.72; opacity:0}}
@keyframes clShack{0%{translate:0 0; rotate:0deg}100%{translate:5px -7px; rotate:38deg}}
@keyframes clDrop{0%{transform:none}22%{transform:scale(1.3) rotate(-10deg)}
  38%{transform:scale(1.16) rotate(6deg)}100%{transform:translateY(150px) rotate(64deg) scale(.8); opacity:0}}
@keyframes clPuff{100%{opacity:0; transform:scale(1.3)}}
@keyframes clFlash{0%{background:#fffd}12%{background:#f4c542aa}100%{background:#1b151000}}
@media (prefers-reduced-motion:reduce){   /* ⚠ honour a player who asked for less motion */
  .chainlock.breaking .clnk{animation-duration:.2s}
  .chainlock.breaking .cl-chain,.chainlock.breaking .blk-sh{animation:none}
  .chainlock.breaking .cl-lock{animation:clPuff .3s ease-in forwards}}
/* gear grids — the ITEM in big pixels + its exact numbers, no wasted space */
.fgear-d{font-size:8px; line-height:1.35; opacity:.8; padding:0 2px;}
.cosgrid .fgear-art{width:72px; height:72px;}
.wardart svg,.fgear-art svg{width:100%; height:100%; image-rendering:pixelated;}
/* THE GRAND TOUR — spotlight + host card */
#tutOv{position:fixed; inset:0; z-index:300;}
.tut-spot{position:absolute; border:3px dashed var(--gold); border-radius:10px; box-shadow:0 0 0 4000px #1b1510b8, 0 0 18px #f4c542aa; animation:tutPulse 1.2s ease-in-out infinite; pointer-events:none;}
@keyframes tutPulse{0%,100%{border-color:var(--gold)}50%{border-color:#fff}}
.tut-card{position:absolute; left:50%; transform:translateX(-50%); width:min(88%,340px); z-index:2;}
/* ⭐⭐ v0.420 — THE TOUR TALKS FROM THE TOP. The bottom of every screen belongs to the D-pad, so a
   card anchored down there is a card you cannot read. It is pinned under the top edge instead, and
   capped so a long step can never grow down into the controls — it scrolls inside itself first. */
.tut-top{top:max(10px, env(safe-area-inset-top)); bottom:auto;
  max-height:calc(100% - 260px); overflow-y:auto; scrollbar-width:none;}
.tut-top::-webkit-scrollbar{width:0; height:0; display:none;}
.btn.inkblk{background:var(--ink)!important; border-color:var(--ink)!important;}
.btn.inkblk span{color:var(--cream)!important;}
/* the INKLET BOUNCE — a 4-frame jolly hop, same smile always */
@keyframes inkBounce{0%{transform:translateY(0) scaleY(1)}25%{transform:translateY(-2px) scaleY(1.02)}50%{transform:translateY(-4px) scaleY(1.05)}75%{transform:translateY(-1px) scaleY(.96)}100%{transform:translateY(0) scaleY(1)}}
.rinkInk svg,#hzChar .hzCharArt svg,.lob-fighter .lob-art svg,.cc-drop svg,.rk-you .rk-av svg{animation:inkBounce 1.1s steps(4) infinite; transform-origin:50% 100%;}
/* retro RADIO sliders — square bits marching left to right, no ovals anywhere */
input[type=range]{-webkit-appearance:none; appearance:none; height:20px; background:transparent; cursor:pointer;}
input[type=range]::-webkit-slider-runnable-track{height:12px; border:2px solid var(--ink); background:repeating-linear-gradient(90deg, var(--ink) 0 7px, transparent 7px 13px); border-radius:0;}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none; width:14px; height:22px; margin-top:-7px; background:var(--gold); border:3px solid var(--ink); border-radius:0; box-shadow:2px 2px 0 #0006;}
input[type=range]::-moz-range-track{height:12px; border:2px solid var(--ink); background:repeating-linear-gradient(90deg, var(--ink) 0 7px, transparent 7px 13px); border-radius:0;}
input[type=range]::-moz-range-thumb{width:14px; height:22px; background:var(--gold); border:3px solid var(--ink); border-radius:0;}
/* the LOBBY STAGE — a bit-brawl field in a box: sky, skyline, meadow */
.lob-stage{position:relative; border:3px solid var(--ink); border-radius:8px; min-height:238px; padding:16px 12px 10px; overflow:hidden;
  background:linear-gradient(180deg,#bfe3f7 0 52%, #cfe3b8 52% 66%, #d8c9a6 66%); box-shadow:inset 0 0 0 2px #ffffff44;}
.lob-stage::before{content:''; position:absolute; left:0; right:0; top:30%; height:24%; opacity:.4; pointer-events:none;
  background:repeating-linear-gradient(90deg, #8a7f6a 0 24px, transparent 24px 38px, #9a8f78 38px 60px, transparent 60px 78px);}   /* the pixel skyline on the horizon */
/* ⭐⭐ v0.404 — THE PLAYING FIELD, BEHIND THE VS. Odaroo asked for the battlefield perimeter to be
   visible behind every matchup card, the way the brawl's arena outline used to read — so the two
   inklings are standing somewhere, not floating on wallpaper. It is one element: a trapezoid in
   perspective with a centre line, drawn in the same dark ink as every other outline in the game.
   ⚠ It is pointer-events:none and sits UNDER the fighters (z-index 1 against their 2), so it can
   never eat a tap meant for a bubble or a button. */
.lob-field{position:absolute; left:6%; right:6%; bottom:8%; height:46%; z-index:1; pointer-events:none;
  border:2px solid #2b2018; border-top-width:2px; border-radius:2px;
  background:linear-gradient(180deg,#00000010,#00000022);
  transform:perspective(160px) rotateX(46deg); transform-origin:50% 100%; opacity:.55;}
.lob-field::before{content:''; position:absolute; left:0; right:0; top:50%; height:2px; background:#2b2018; opacity:.7;}
.lob-field::after{content:''; position:absolute; left:50%; top:0; bottom:0; width:2px; margin-left:-1px; background:#2b2018; opacity:.55;}
.lob-stage .lob-fighter{position:relative; z-index:2;}
.connLine{margin-top:8px; text-align:center; font-family:var(--fontD); font-size:12px; color:var(--red);
  text-shadow:1px 1px 0 #fff; letter-spacing:1px;}   /* MATCH FOUND — safely below the stage box */
.lob-tag.lob-1line{white-space:nowrap!important; max-width:none!important; overflow:visible;}   /* v0.375 — the connecting stage's two names, never broken across lines */
.lob-tag.lob-plain{background:none!important; border:0!important; box-shadow:none!important; color:#fff!important; padding:0;
  text-shadow:1px 1px 0 #1b1510, -1px -1px 0 #1b1510, 1px -1px 0 #1b1510, -1px 1px 0 #1b1510;}   /* the NAME, plain white with an ink outline — no plate behind it */
/* ⚠⚠⚠ v0.575 — THE VS-SCREEN SIGN NO LONGER USES THIS. (Odaroo, 2026-08-04: "just the bare text
   connecting... with its three dots — no box, no container, no film reel behind it.") The class is
   simply not put on `#connNm` / `#crcNm` any more (brawl.js, online.js), so the word sits plain on
   the stage. The rule below is kept because `#toast` still wears it — do not re-attach it to the
   lobby sign. The HUD reconnect banner (#crLineBadge) is a different sign and is untouched.
   ⭐⭐ v0.567 — CONNECTING IS A STRIP OF FILM; A REVEALED NAME IS STILL PLAIN. (Odaroo, 2026-08-04:
   the film reel is the house style for connecting messages.)
   ⚠⚠ THIS WAS TAKEN OFF IN v0.572 AND PUT STRAIGHT BACK IN v0.573 — Odaroo changed his mind and
   wants the film reel on CONNECTING/CONNECTED, enhanced rather than removed. The enhancement lives
   in the reconnect banner (#crLineBadge, css/crown.css): it now sits where the game HUD strip sits
   and carries the player's own inkling. Do not delete this rule again on the strength of v0.572.
   ⚠⚠ THE "NO PLATES ON ANY NAME" RULE ABOVE IS NOT BEING REVERSED. The v0.375 decision was about
   the two USERNAMES on the VS stage, and it still holds — the moment the rival's name lands, the
   JS takes .inkfilm-sign straight back off (brawl.js/online.js) and the name is plain white with
   its ink outline exactly as before. This dresses the STATUS WORD only, for the seconds it is up.
   ⚠ `!important` is forced on background-color/padding because .lob-plain sets them !important and
   this rule has to win; background-IMAGE is untouched by that, so the sprockets come through from
   the shared rule at the top of this file. */
.lob-tag.lob-plain.inkfilm-sign{
  background-color:#0d0a08!important; color:#fff!important;
  background-image:linear-gradient(to right,
     transparent 0 calc((var(--film-per) - var(--film-w))/2 * var(--gs,1)),
     var(--film-hole) calc((var(--film-per) - var(--film-w))/2 * var(--gs,1)) calc((var(--film-per) + var(--film-w))/2 * var(--gs,1)),
     transparent calc((var(--film-per) + var(--film-w))/2 * var(--gs,1)) calc(var(--film-per) * var(--gs,1))),
    linear-gradient(to right,
     transparent 0 calc((var(--film-per) - var(--film-w))/2 * var(--gs,1)),
     var(--film-hole) calc((var(--film-per) - var(--film-w))/2 * var(--gs,1)) calc((var(--film-per) + var(--film-w))/2 * var(--gs,1)),
     transparent calc((var(--film-per) + var(--film-w))/2 * var(--gs,1)) calc(var(--film-per) * var(--gs,1)))!important;
  background-size:calc(var(--film-per) * var(--gs,1)) calc(var(--film-h) * var(--gs,1)), calc(var(--film-per) * var(--gs,1)) calc(var(--film-h) * var(--gs,1))!important;
  background-position:center calc(var(--film-in) * var(--gs,1)), center calc(100% - var(--film-in) * var(--gs,1))!important;
  background-repeat:repeat-x, repeat-x!important;
  padding:10px 14px!important; text-shadow:none;
  box-shadow:0 3px 0 rgba(13,10,8,.55);}
.lob-tag.lob-plain.inkfilm-sign #cDots{height:auto; margin-top:2px;}
/* the SUMMONING — glitch beam + the ink digging itself out of the floor */
.cupFall{position:fixed; z-index:96; width:20px; height:20px; pointer-events:none; animation:cupFallA .95s ease-in forwards;}
.cupFall svg{width:100%; height:100%;}
@keyframes cupFallA{0%{opacity:1; transform:translateY(0) rotate(0)}100%{opacity:0; transform:translateY(74vh) rotate(140deg)}}
.sumCrack{position:absolute; width:54px; height:22px; margin-left:-27px; z-index:5; pointer-events:none; image-rendering:pixelated;
  animation:crackA 1.05s steps(3) forwards;}   /* BIGGER, dirtier — the ground is clearly being dug out of */
.sumCrack svg{width:100%; height:100%; display:block; filter:drop-shadow(0 1px 0 #0006);}
@keyframes crackA{0%{opacity:0; transform:scaleX(.3)}22%{opacity:1; transform:scaleX(1)}78%{opacity:1}100%{opacity:0}}
.unit.spawn{animation:sumIn .9s both!important; transform-origin:50% 100%;}   /* DIGLETT-style — the piece rises IN PLACE from the dirt, one clean second */
@keyframes sumIn{0%{transform:scaleY(.1); opacity:0}
  10%{transform:scaleY(.18); opacity:1}
  30%{transform:scaleY(.3)}
  45%{transform:scaleY(.26)}
  70%{transform:scaleY(.72)}
  86%{transform:scaleY(1.05)}
  100%{transform:scaleY(1)}}
.unit.walk .inkform, .unit.walk svg{animation:uWaddle .48s steps(2) infinite; transform-origin:50% 100%;}   /* the paper-mario STOMP — a nominal penguin tilt, left, right */
@keyframes uWaddle{0%{transform:rotate(-3.5deg)}50%{transform:rotate(3.5deg)}100%{transform:rotate(-3.5deg)}}
/* BATTLE START curtain — announced BETWEEN the inklets, the deck stays visible */
.ssOverlay.bsOv{animation:bsFade 1.7s both; position:fixed; inset:0; z-index:9990; justify-content:flex-start; padding-top:34px;
  background:radial-gradient(ellipse at 50% 12%, #1b1510c4 0%, #1b151055 42%, transparent 68%);}   /* GRANDIOSE — pinned to the TOP under the versus bar so it never gets cut off below */
@keyframes bsFade{0%{opacity:0}10%{opacity:1}82%{opacity:1}100%{opacity:0}}
.ssOverlay.bsOv.bsOv-battle{padding-top:92px;}   /* v3.52 — Bit-Brawl banners (BATTLE START / RIVAL SHOWSTOPPER) slide in UNDER the timer + HP strip, never over them */
.bsOv .ss-jp{font-size:clamp(30px,11vw,52px); color:#f4c542;
  text-shadow:3px 3px 0 #1b1510,-3px -3px 0 #1b1510,3px -3px 0 #1b1510,-3px 3px 0 #1b1510,0 0 26px #f4c542;
  animation:bsSlam .5s var(--pop) both;}
.bsOv .ss-en{font-size:clamp(20px,8vw,36px); font-weight:bold; color:#fff; letter-spacing:2px;
  text-shadow:3px 3px 0 #c93a22,-2px -2px 0 #1b1510,2px -2px 0 #1b1510,-2px 2px 0 #1b1510,4px 4px 0 #1b1510;
  animation:bsSlam .6s var(--pop) both;}
@keyframes bsSlam{0%{transform:scale(2.4); opacity:0}60%{transform:scale(.92); opacity:1}100%{transform:scale(1)}}
/* ⭐⭐⭐ v0.818 — THE CLOCK NEVER GROWS, GLOWS OR STEPS. (Odaroo, 2026-08-14: "remove that
   animation where the clock is showing 30 seconds, zoomed in and then zoomed out. Completely
   remove that.") Both rules are DELETED rather than merely unhooked from their one caller, so a
   future `classList.add('clkBig')` cannot bring the zoom back by accident. The keyframes below
   are kept as headstones only because a `@keyframes` nobody names costs nothing and deleting the
   name is how a half-finished removal leaves a dangling reference elsewhere. */
/* ⭐⭐⭐ v0.712 — THE POP ANIMATES `scale`, THE INDEPENDENT PROPERTY, AND NEVER `transform`.
   #bClock is the header reel now, and `.vsmid` is centred by `transform:translate(-50%,-50%)`. A
   keyframe on `transform` REPLACES that translate outright for the 1.1 seconds it runs, so the DING
   DING would have thrown the clock a half-width to the right and dropped it half a line, then
   snapped it back — reading as the header breaking rather than as the countdown waking up.
   `scale` is its own property and COMPOSES with the translate, so the plate grows about its own
   centre and stays exactly where it is. Same fix, same reason, as the vault tile breathing
   alongside featJit (see the CLAUDE.md note on `animation` being a shorthand). */
@keyframes clkBigA{0%{scale:1}25%{scale:1.55}70%{scale:1.45}100%{scale:1}}
@keyframes clkSh{50%{box-shadow:0 0 18px #f4c542, 2px 2px 0 #000a; filter:brightness(1.6)}}
/* the BIGGER lobby — headroom for a fully-hatted inklet, nothing clipped */
#modalBox .lobby{min-height:0; display:flex; flex-direction:column; justify-content:flex-start; padding-top:6px;}   /* the lobby sizes to its content — no empty stage below DIP */
.lobby .lob-hub{margin-top:14px;}
.lobby .lob-fighter{overflow:visible;}
.lobby .lob-fighter .lob-art{width:68px; height:80px; overflow:visible;}   /* v3.41 — the fighters ARE the attraction: ~20% bigger on the VS stage */
.lobby .lob-fighter .lob-art svg{overflow:visible; width:100%; height:100%;}
/* the SAVED cartridge turns GOLD and shines */
.cartGold svg{filter:sepia(1) saturate(4) hue-rotate(-14deg) brightness(1.2) drop-shadow(0 0 10px #f4c542cc); animation:pulse 1s ease-in-out infinite;}
/* the saving cartridge holds STILL — just a soft bounce, no seizure */
.savebox .saveGlitch{animation:none!important;}
/* ============================================================
   ⭐⭐ v0.417 — EVERY LINE IN THE SAVE RITUAL IS THE SAME SIZE, AND NONE OF THEM ORPHAN A WORD.
   The four screens had three different sizes between them: the question typed at one size, the
   DON'T TURN OFF THE POWER warning at two thirds of it, and the sign-off at two thirds again with
   `white-space:nowrap` on top — so it could not wrap at all and simply ran out of its box on a long
   name. They now share one class and one size.
   The line breaks are AUTHORED rather than left to the browser. Measured on the folded phone, the
   box holds exactly 18 characters of this face per line, so every message is pre-split into lines
   that fit inside that on the narrow screen and stay balanced on the wide one — which is what stops
   a single word being left stranded on a line of its own.
   ⚠ `.modal .disp` sets 15px and beats an inline style on specificity, which is how the old inline
   `font-size:12px` on the question was being silently ignored. That is why this carries !important:
   it has to win the same fight, deliberately this time.
   ============================================================ */
.modal .disp.save-line{font-size:15px !important; line-height:1.55; letter-spacing:1px;
  text-align:center; white-space:normal; overflow:visible; text-wrap:balance;}
/* ⚠ NOT var(--red). The house red is #c93a22 and on the cream receipt it measures 4.1:1 — under the
   4.5 floor, and this is the one line on the screen that is genuinely a warning. A shade darker
   clears it while still reading as the same red. (This is the third time the house red has failed
   on cream; it is fine on the dark plates, not on paper.) */
.save-warn{color:#9e2a17; display:block; margin-top:4px;}   /* same size as everything else here — it inherits */
/* ⚠ v0.417 — AND THE SIGN-OFF WAS UNREADABLE, which is nothing to do with the size and had been
   true the whole time: dark gold on the cream receipt measures 2.9:1, well under the 4.5 floor.
   crReadable flagged it the moment the line was big enough to look at properly. It takes the same
   dark plate the cup payout got in v0.412 — the gold stays gold, and on near-black it is emphatic
   rather than faint. */
.modal .disp.savedone{font-size:15px !important; color:var(--gold) !important;
  background:#1b1510; border:2px solid var(--gold); padding:7px 10px; width:fit-content; margin:0 auto;}
.savebox .saveGlitch svg{animation:inkBounce 1.3s steps(4) infinite; transform-origin:50% 100%;}
/* the CONNECTING mystery shadow */
/* ⭐⭐⭐ v0.648 — A SILHOUETTE IS BLACKED OUT WHOLE, CONTAINER AND ALL. (Odaroo, 2026-08-05: "during
   the roulette animation an inklet appears that's NOT covered in silhouette — it flashes visible
   briefly. All inklets in the roulette should be silhouettes UNTIL the final one is selected.")
   ⚠⚠ IT WAS THE SHINIES, AND ONLY THE SHINIES — a 14% roll in randRivalInklet wraps the drawing in
   `.shinyInk`, which runs an ANIMATED filter of its own: `brightness(1.35) drop-shadow(0 0 8px
   #ffe58a)`. Blackening the inner `svg` never touched that wrapper, so every seventh face in the
   roulette pulsed a white-gold glow around itself — a silhouette wearing a halo, which is precisely
   "briefly visible". The filter is on the CONTAINER now, so anything inside it (glow, ring, future
   deco) is flattened together, and the shiny's animation is switched off inside a silhouette.
   ⚠ Do not move this back onto `svg`: the whole failure was a descendant painting outside it. */
/* ⚠⚠⚠ AND IT IS WRITTEN AS TWO CLASSES ON PURPOSE. `.lob-art` (css/home.css) sets a drop-shadow
   filter of its own, and home.css loads AFTER base.css — so a plain `.lob-shadow{filter:…}` here is
   the same specificity and LOSES on source order, silently. The silhouette check caught exactly that
   (computed filter came back as the drop-shadow). `.lob-art.lob-shadow` is 0,2,0 and cannot lose to
   a single class whatever the load order is. Same trap as the Tetr-Ink run-box rule (v0.551). */
.lob-art.lob-shadow, .lob-shadow{animation:shBob 1.1s ease-in-out infinite; filter:brightness(0) opacity(.85);}
.lob-shadow svg{filter:none;}
.lob-shadow .shinyInk{animation:none!important; filter:none!important;}
/* ⚠ v0.648 — POSITIONED, so a frame's ring (inset:-3px, absolute) anchors to the portrait it rings
   instead of escaping to the dialog. This is what let the VS stage wear the player's real frame. */
.lob-art{position:relative;}
/* ⭐⭐⭐ v0.786 — THE CONNECTING ROULETTE IS A STACK, NOT A REPLACEMENT (see inkRouletteMount,
   js/brawl.js). Every possible silhouette is built once and laid on top of the others; the shuffle
   only moves `.on`. They are absolutely positioned so the box cannot resize between two silhouettes
   (a shiny one wraps its art in an extra `.shinyInk` span, so the flow height was not constant),
   and they repeat `.lob-art`'s own flex alignment so a stacked inklet stands on the same floor the
   single one did. ⚠ opacity only — a `display` swap would re-lay-out and re-rasterise, which is
   exactly the cost this replaced. */
.lob-art>.rouFrame{position:absolute; inset:0; display:flex; align-items:flex-end; justify-content:center; opacity:0;}
.lob-art>.rouFrame.on{opacity:1;}
.lob-art>.rouFrame svg,.lob-art>.rouFrame .shinyInk{width:100%; height:100%; overflow:visible;}
@keyframes shBob{0%,100%{transform:translateY(0)}50%{transform:translateY(-7px)}}
.lob-float{animation:shBob 1.3s ease-in-out infinite;}   /* YOU hover while the line dials */
/* PERSONA-style dive into the fight */
#modalBox.p5zoom{animation:p5dive .62s cubic-bezier(.6,-.2,.9,.4) forwards;}
@keyframes p5dive{0%{transform:scale(1) rotate(0); filter:none; opacity:1}
  45%{transform:scale(1.25) rotate(-2deg); filter:contrast(1.6) saturate(1.6)}
  100%{transform:scale(3.2) rotate(3deg); filter:contrast(2.2) saturate(2) blur(3px); opacity:0}}
/* THE VS DIVE — the letters burn, glitch, and swallow the screen */
#vsZoom{position:absolute; inset:0; z-index:9800;   /* v0.360 - IN FRONT OF EVERYTHING: it was at 150, behind the lobby pop-up (400) and the pad (460), so the dive played out of sight */ display:flex; align-items:center; justify-content:center; pointer-events:none; overflow:hidden; animation:vsBg 1.1s ease-in forwards;}
@keyframes vsBg{0%{background:#0000}45%{background:#1b1510aa}100%{background:#0a0a0d}}
#vsZoom .vs-word{position:relative; animation:vsZoomIn 1.05s cubic-bezier(.5,-.1,.85,.4) forwards;}
@keyframes vsZoomIn{0%{transform:scale(.35); opacity:0}16%{transform:scale(1); opacity:1}68%{transform:scale(3.2)}100%{transform:scale(11)}}
#vsZoom .vs-in{display:block; font-family:var(--fontD); font-size:60px; letter-spacing:5px; color:#ffdd55;
  text-shadow:0 0 12px #ff7a2e, 0 0 30px #ff3b1f, 3px 3px 0 #1b1510, -2px -2px 0 #c93a22;
  animation:vsBurn .13s steps(2) infinite, vsGl .34s steps(2) infinite;}
@keyframes vsBurn{50%{text-shadow:0 0 20px #ffb02e, 0 0 48px #ff3b1f, 3px 3px 0 #1b1510, -2px -2px 0 #c93a22; filter:brightness(1.3)}}
@keyframes vsGl{0%,76%{transform:skewX(0) translateX(0)}84%{transform:skewX(7deg) translateX(2px); filter:hue-rotate(100deg)}92%{transform:skewX(-6deg) translateX(-2px); filter:invert(.18)}}
#vsZoom .vs-fl{position:absolute; left:50%; top:38%; width:9px; height:13px; background:#ff7a2e; animation:vsFl .55s ease-in infinite;}
#vsZoom .vs-fl.f2{margin-left:-30px; background:#ffdd55; animation-delay:.18s;}
#vsZoom .vs-fl.f3{margin-left:20px; background:#ff3b1f; animation-delay:.34s;}
@keyframes vsFl{0%{transform:translateY(8px) scaleY(.5); opacity:0}30%{opacity:1}100%{transform:translateY(-42px) scaleY(1.5); opacity:0}}
/* ============================================================
   ⭐⭐⭐ v0.481 — THE LINK CABLE. The way into EVERY online match.

   Odaroo: two Game Boys joined by a link cable, white-on-black neon, Japanese writing, and then the
   camera dives INTO the wire — electricity, data, glitch, sparks, binary.

   Three acts, and the whole thing is drawn by the browser: no images, no video.
     1  LINK      the two handhelds slide in and the cable snaps taut between them
     2  DIVE      the whole rig races toward the camera and the cable's mouth swallows the screen
     3  WIRE      inside: a tunnel of neon rings, sparks and streaming binary, then white-out

   ⚠ z-index 9801 — ONE ABOVE #vsZoom, which learned in v0.360 that 150 puts a transition behind the
   lobby pop-up (400) and the D-pad (460) and it plays out of sight.
   ⚠ pointer-events:none throughout: a transition may never eat a tap meant for the game behind it.
   ⚠ EVERY colour here is white/cyan on black BY DESIGN — this is the one screen in the game that is
   not on cream paper, so the readability floor the rest of the game lives by does not apply: there
   is nothing here to read except two names in pure white on pure black.
   ============================================================ */
/* ⭐⭐⭐ v0.492 — THE LINK CABLE SITS UNDER THE D-PAD, NOT OVER IT. THIS IS THE "CUT OFF" ARROW.
   ⚠⚠ The pad is not clipped and never was — measured at both folds, the cross box sits fully inside
   #gpad with room under it. What was happening is that this overlay is `position:fixed; inset:0` at
   z-index 9801, so during the whole connecting animation it was painted straight OVER the controls,
   and what shows through at the bottom edge of a phone (where a fixed overlay and the visual
   viewport disagree as the URL bar collapses) is the top half of a D-pad with its lower arm hidden.
   That reads exactly like a clipped down arrow, which is what Odaroo recorded.
   ⚠⚠ AND IT BREAKS A GOLDEN RULE. "The pad stays VISIBLE and keeps its colours and its place — it
   only dims" (v0.485, the connecting lock). An opaque sheet over the top of it is not dimming.
   450 is chosen deliberately: ABOVE the modal backdrop (400), so the animation still covers the
   connecting card, and BELOW the pad (460), so the controls ride over the animation intact. */
.gblink{position:fixed; inset:0; z-index:450; background:#000; overflow:hidden; pointer-events:none;
  display:flex; align-items:center; justify-content:center;}
/* the rig: both handhelds and the cable. It does NOT move — see the OK! block below. */
.gblink .gbl-rig{position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center;
  animation:gblIn .42s ease-out both; transition:opacity .3s ease, filter .3s ease;}
/* ⭐⭐⭐ v0.503 — BOTH HANDHELDS ARE THERE FROM THE FIRST FRAME, AND THEY NEVER GO ANYWHERE.
   (Odaroo: "show the two Game Boys connected, same as before, keep that visual.")
   The rig used to open at scale .55 and grow, and then dive at the wire. Both are gone. It fades
   up at its final size and holds; the only thing that ever moves in this animation now is the OK!
   ⚠ IT DIMS BEHIND THE OK! RATHER THAN LEAVING. The confirmation is about the picture underneath
   it, so the picture has to still be there to be confirmed. */
@keyframes gblIn{0%{opacity:0}100%{opacity:1}}
.gblink.ok .gbl-rig{opacity:.45; filter:blur(1.5px) brightness(.7);}
.gblink.zoom .gbl-rig{opacity:.2;}
/* ============================================================
   ⭐⭐⭐ v0.503 — OK! AND THE FALL INTO IT.
   A huge, bold, dead-centre OK! punches in over the linked handhelds, then the screen falls into
   it. Two rules and two keyframe sets, and that is the whole of the transition now.
   ⚠⚠ THE ZOOM SCALES THE WORD, NOT THE OVERLAY — a transform on .gblink.zoom itself would drag
   the two handhelds up with it, which is exactly the dive-into-the-wire this build deletes.
   ⚠ The word is sized in vmin so it is the same proportion of the screen on both folds
   — an OK! measured against WIDTH would be a third of the height folded and fill it unfolded.
   ⚠ The punch OVERSHOOTS and settles (a 1.18 at 55%): it has to land like a stamp, not fade up.
   ⚠⚠ 26vmin, NOT 34. The first pass was 34vmin with 2vmin of letter-spacing, and the pre-flight
   measured the word at 405px inside a 375px fold — "OK!" is three glyphs, so in this face it comes
   out about 3.0x the font size before spacing is added, and a size that fits the HEIGHT can still
   run off the sides. It measures 303px folded / 672px unfolded now: still most of the screen,
   which is the "huge" that was asked for, and on the screen at both folds, which is the rule.
   ============================================================ */
/* ⭐⭐⭐ v0.518 — NO EXCLAMATION POINT, AND オッケー UNDERNEATH IT. (Odaroo: "just OK, centred, with
   the Japanese equivalent below it filling the same width — not miniature.")
   The two lines are one BLOCK (.gbl-okbox) and the block is what punches in and what the screen
   falls into, so the pair always moves as one object and stays dead-centre on the screen.
   ⚠ THE JAPANESE'S SIZE IS SET BY js/brawl.js, NOT HERE. The 8vmin below is only a starting size to
   measure from; the script rescales it so its width equals the OK's exactly. Two faces and two
   scripts cannot be matched by any number written in a stylesheet.
   ⚠ The OK's trailing letter-spacing is cancelled with a negative margin, or the measured width
   carries a phantom 1vmin of air after the K and the Japanese comes out that much too wide. */
.gblink .gbl-ok{position:absolute; inset:0; z-index:4; display:flex; align-items:center; justify-content:center;
  opacity:0; pointer-events:none;}
.gblink .gbl-okbox{display:block; text-align:center; transform:scale(.2);}
/* ⚠⚠ THE TWO LINES ARE BLOCKS SO THEY STACK — WHICH MEANS THEIR RECTS ARE THE BOX'S WIDTH, NOT
   THEIR TEXT'S. js/brawl.js therefore measures each line with a Range over its text node, never
   with getBoundingClientRect on the element: a block shrink-wraps to its PARENT, so element rects
   would make the ratio come out 1.00 whatever the words say — a fit that cannot fail is not a fit.
   (Measured before the Range: both lines reported ~43px and the Japanese was left 7% too narrow.) */
.gblink .gbl-okw{display:block; font-family:var(--fontD); font-weight:900; font-size:26vmin; line-height:1;
  letter-spacing:1vmin; margin-right:-1vmin; color:#fff; text-shadow:0 0 4vmin #7ff6ff, 0 0 9vmin #2ad, 0 .8vmin 0 #0a2b38;
  -webkit-text-stroke:.7vmin #07141a; font-style:normal;}
.gblink .gbl-okj{display:block; font-family:var(--fontD); font-weight:900; font-size:8vmin; line-height:1.05;
  margin-top:.6vmin; color:#ffe27a; font-style:normal; white-space:nowrap;
  text-shadow:0 0 3vmin #7ff6ff, 0 .5vmin 0 #0a2b38; -webkit-text-stroke:.35vmin #07141a;}
.gblink.ok .gbl-ok{opacity:1;}
/* ⚠ FAST: the punch is .22s now, not .34s, and it still overshoots — it has to land like a stamp. */
.gblink.ok .gbl-okbox{animation:gblOkIn .22s cubic-bezier(.2,1.6,.4,1) both;}
@keyframes gblOkIn{0%{transform:scale(.2) rotate(-9deg); opacity:0}
  55%{transform:scale(1.16) rotate(3deg); opacity:1}
  100%{transform:scale(1) rotate(0); opacity:1}}
/* the fall: the pair grows past the edges of the screen and the BLACK-out comes up behind it */
.gblink.zoom .gbl-okbox{animation:gblOkZoom .40s cubic-bezier(.5,0,.85,.4) forwards;}
@keyframes gblOkZoom{0%{transform:scale(1)}100%{transform:scale(11); opacity:.85}}
/* ⚠⚠ THE RESTING TILT IS A PLAIN DECLARATION, NOT THE END OF AN ANIMATION. Until v0.502 the two
   handhelds got their opposite tilts from the last keyframe of their slide-ins, held there by
   `fill:both` — so deleting the slides (v0.502) or the glitch (v0.503) would each have quietly
   stood both of them bolt upright. Keep it declared here: a resting state that only exists as the
   end of an animation disappears the moment that animation is cut. */
.gblink .gbl-gb{position:relative; width:34%; max-width:190px; filter:drop-shadow(0 0 6px #7ff6ffaa);
  transform:rotate(var(--gbrot,0deg));}
.gblink .gbl-gb.l{--gbrot:-6deg;}
.gblink .gbl-gb.r{--gbrot:6deg;}
.gblink .gbl-gb svg{display:block; width:100%; height:auto;}
/* the cable itself — a taut arc between the two ports, pulsing with what is running through it */
.gblink .gbl-wire{width:20%; max-width:120px; align-self:center; margin:0 -2%;}
.gblink .gbl-wire svg{display:block; width:100%; height:auto; overflow:visible;}
.gblink .gbl-spark{stroke:#fff; stroke-width:2; fill:none; filter:drop-shadow(0 0 4px #7ff6ff);
  stroke-dasharray:6 10; animation:gblRun .5s linear infinite;}
@keyframes gblRun{to{stroke-dashoffset:-32}}
/* the little screens: each one carries that player's own inkling, in Game Boy green */
.gblink .gbl-scr{position:absolute; display:flex; align-items:center; justify-content:center; overflow:hidden;
  background:#0d1f14; box-shadow:inset 0 0 0 2px #7ff6ff55;}
/* ⭐⭐⭐ v0.652 — THE TWO LITTLE SCREENS ARE IN FULL COLOUR. (Odaroo, 2026-08-05: "on the Game Boy
   we just see the two inklets with each other, they're both coloured on the screen — it's no longer
   grayscale.") The green Game Boy tint (saturate .25 / sepia / hue-rotate 55deg) washed both inklets
   to the same pea-soup silhouette, so a player could not tell their own from their rival's. The well
   is still a dark handheld screen; the sprites on it are simply themselves now.
   ⚠ DO NOT PUT A `filter` BACK ON THIS SELECTOR. It is the whole point of the change. */
.gblink .gbl-scr svg{width:74%; height:74%;}
.gblink .gbl-hold{position:relative;}
/* the writing: white neon, flickering, Japanese under the English */
.gblink .gbl-say{position:absolute; left:0; right:0; text-align:center; font-family:var(--fontD); color:#fff;
  text-shadow:0 0 6px #7ff6ff, 0 0 18px #2ad; animation:gblFlick .42s steps(2) infinite;}
.gblink .gbl-say.top{top:9%; font-size:13px; letter-spacing:3px;}
.gblink .gbl-say.bot{bottom:11%; font-size:9px; letter-spacing:2px; opacity:.85;}
.gblink .gbl-say i{display:block; font-style:normal; font-size:8px; letter-spacing:2px; opacity:.75; margin-top:4px;}
@keyframes gblFlick{0%,88%{opacity:1}92%{opacity:.35}96%{opacity:1; transform:translateX(2px)}}
/* the two names, one under each handheld */
.gblink .gbl-nm{position:absolute; bottom:-22px; left:0; right:0; text-align:center; font-family:var(--fontD);
  font-size:8px; letter-spacing:1px; color:#fff; text-shadow:0 0 6px #7ff6ff; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
/* the BLACK-out that hands the screen to the game */
/* ⭐⭐⭐ v0.518 — IT IS BLACK NOW, NOT WHITE, AND THAT IS THE SEAM. v0.517 moved the black curtain
   inside gameSplash(), so every game's entrance now opens on black — and a white flash immediately
   before it made the handover read as a strobe: black rig → white → black splash. Ending on the
   same black the splash begins on means there is no transition to see at all; the OK simply
   swallows the screen and the game's title card is already there.
   ⚠⚠ IT RIDES THE ZOOM. `.34s ease-in .06s` off the zoom act = 900+60+340 = 1300ms, which is
   GBL_HAND in js/brawl.js, the frame the game is handed the screen. THOSE TWO NUMBERS ARE ONE
   NUMBER SPLIT ACROSS TWO FILES: move either alone and the game either arrives behind a half-black
   screen or waits on a fully black one.
   ⚠ The 60ms delay is what lets the OK visibly start growing before the dark takes it.
   ⚠ z-index 5 puts it over the OK (4). It is the curtain; everything goes behind it. */
.gblink .gbl-flash{position:absolute; inset:0; z-index:5; background:#000; opacity:0;}
.gblink.zoom .gbl-flash{animation:gblFlash .34s ease-in .06s forwards;}
@keyframes gblFlash{0%{opacity:0}70%{opacity:1}100%{opacity:1}}
/* ⚠ the hand-over is a FADE, not a cut. The screen is pure white when the game arrives behind it;
   yanking the overlay away in one frame reads as a flicker rather than as an arrival. */
.gblink.out{animation:gblOut .24s ease-out forwards;}
@keyframes gblOut{to{opacity:0}}
/* ⭐⭐⭐ v0.485 — THE CONNECTING LOCK, AS A LOOK. (Odaroo: keep the same colours, just signal it.)
   The pad is still there and still exactly where it was — the refusal happens at gpad(), not here.
   This only says so: a small drop in strength and a slight desaturation, enough to read as asleep
   without turning the controls into a different object.
   ⚠ NO `display`, NO `visibility`, NO layout property of any kind. The pad's whole existence is
   protected by padWanted/padForce (v0.376, after Odaroo lost the D-pad and could not get it back),
   and a transition state must never be able to hide it.
   ⚠ pointer-events stays ON so a tap still lands and is still refused by the one guard, rather than
   falling through to whatever is behind the pad. */
#gpad.gp-locked{filter:saturate(.6); transition:filter .18s ease;}   /* ⚠ opacity is set INLINE by padForce - see the note there. Only the desaturation lives here. */
/* FLASH GIG banner — stays up top until the player says OK */
#fgBanner{position:fixed; top:8px; left:50%; transform:translateX(-50%); z-index:97; background:var(--cream); color:var(--ink); border:3px solid var(--ink); box-shadow:3px 3px 0 var(--ink); border-radius:8px; padding:8px 12px; display:flex; gap:10px; align-items:center; max-width:92%; animation:fgIn .4s var(--pop);}   /* the SAME block style as a gig row — not a red alarm */
#fgBanner .fg-t{font-family:var(--fontD); font-size:9px; line-height:1.8;}
#fgBanner .fg-t b{color:var(--red);}
#fgBanner .fg-ok{font-family:var(--fontD); font-size:10px; background:#2e9e4f; color:#fff; border:2px solid var(--ink); padding:6px 12px; cursor:pointer;}   /* GIGS = go = GREEN */
#fgBanner{overflow:hidden; position:fixed;}
#fgBanner .fg-bar{position:absolute; top:0; left:0; right:0; height:3px; background:#1b151022;}
#fgBanner .fg-bar i{display:block; height:100%; width:100%; transform-origin:left center; background:var(--gold); animation:fgDeplete 5s linear forwards;}   /* the quiet countdown */
/* ⚠⚠ v0.834 — the countdown empties on `scaleX`, not on `width`: `width` is a layout property and
   a 5s linear drain on it re-solved the page every frame for the whole five seconds the banner was
   up. The bar is a FLAT colour, so scaling it is pixel-identical — ⚠ this substitution is only
   safe on a solid fill. A bar carrying a linear-gradient would have its gradient squashed with it. */
@keyframes fgDeplete{from{transform:scaleX(1)}to{transform:scaleX(0)}}
@keyframes fgIn{from{transform:translateX(-50%) translateY(-44px); opacity:0}}
.tut-tap{position:fixed; width:44px; height:44px; margin:-22px 0 0 -22px; border-radius:50%; pointer-events:none; z-index:2;
  border:3px solid var(--gold); box-shadow:0 0 0 2px #fdf6e3, 0 0 14px #f4c542aa; animation:tutTap 1.3s ease-out infinite;}   /* the tap ripples — gold-and-white, same edge as the spotlight */
@keyframes tutTap{0%{opacity:0; transform:scale(.25)}25%{opacity:1}100%{opacity:0; transform:scale(1.25)}}
.tut-bub{background:var(--ink); color:var(--cream); border:3px solid var(--paper); box-shadow:4px 4px 0 #000a; padding:11px 12px; text-align:left;}
.tut-bub .disp{color:var(--gold);}
.tut-txt{font-size:12px; line-height:1.55; margin-top:4px;}
/* VS FRIEND challenge-code boxes */
.codebox{width:100%; height:56px; font-family:ui-monospace,monospace; font-size:9px; background:var(--paper2); color:var(--ink);
  border:2px solid var(--ink); padding:5px 6px; resize:none; word-break:break-all; line-height:1.4; margin:3px 0;}
.codebox:focus{outline:2px solid var(--gold);}
/* FRAMES — sparkle + the BUG FINDER deerstalker */
.hud-av,.file-av,.fr-prev{position:relative;}
.fr-spark{border-color:#f4c542!important; box-shadow:0 0 18px #f4c542cc, 0 0 34px #d9a23255!important;}   /* GOLD, properly */
.fr-spark>svg:first-of-type{animation:inkSpark 4.4s ease-in-out infinite;}   /* the inklet CATCHES the light now and then */
@keyframes inkSpark{0%,82%,100%{filter:drop-shadow(0 0 5px #7ab6ffaa)}86%{filter:brightness(1.9) drop-shadow(0 0 10px #f4c542)}90%{filter:brightness(1.1) drop-shadow(0 0 6px #f4c542)}94%{filter:brightness(2.1) drop-shadow(0 0 14px #ffe9a0)}}
.fr-pipe{position:absolute; left:52%; bottom:34%; width:38%; min-width:18px; z-index:6; pointer-events:none; image-rendering:pixelated;
  filter:drop-shadow(1px 1px 0 #0006); animation:pipeGl 3.4s steps(2) infinite;}   /* the LIMITED-EDITION glitchy pipe */
.fr-pipe svg{width:100%; height:auto; display:block; filter:drop-shadow(0 0 2px #ffffffaa);}   /* the pipe gleams */
@keyframes pipeGl{0%,86%,100%{transform:none; filter:drop-shadow(1px 1px 0 #0006)}89%{transform:translate(1px,-1px) skewX(8deg); filter:hue-rotate(130deg) saturate(2.2) drop-shadow(1px 1px 0 #0006)}93%{transform:translate(-1px,0); filter:invert(.25) drop-shadow(1px 1px 0 #0006)}96%{transform:none; filter:hue-rotate(-60deg) drop-shadow(1px 1px 0 #0006)}}
.fr-sp{position:absolute; font-size:11px; color:#f4c542; text-shadow:0 0 5px #f4c542; animation:frTw 1.2s ease-in-out infinite; pointer-events:none; z-index:4;}
.fr-sp.s1{top:-4px; right:2px;} .fr-sp.s2{bottom:-1px; left:-3px; animation-delay:-.4s;} .fr-sp.s3{top:42%; right:-6px; animation-delay:-.8s;}
.fr-sp.s4{top:8%; left:-6px; animation-delay:-.6s; font-size:9px;} .fr-sp.s5{bottom:14%; right:4%; animation-delay:-1s; font-size:13px;}
@keyframes frTw{0%,100%{opacity:.15; transform:scale(.7) rotate(-8deg)}50%{opacity:1; transform:scale(1.2) rotate(8deg)}}
.fr-hatw{position:absolute; bottom:-10%; left:26%; transform:translateX(-50%) rotate(-14deg) scaleY(-1); width:44%; min-width:19px; z-index:5; pointer-events:none; filter:drop-shadow(1px -2px 0 #0007);}   /* the detective DROPPED his hat — half size, flipped upside-down at the brim */
.fr-hatw svg{width:100%; height:auto; display:block;}
/* ===== BUG FINDER v4 — the case file, the glass & the ghost pipe (no hat) ===== */
.fr-pipe2{position:absolute; left:56%; top:auto; bottom:3%; width:23%; min-width:11px; z-index:8; pointer-events:none; image-rendering:pixelated; opacity:0;   /* the pipe rests ON TOP of the case file */
  animation:pipeFloat 4.2s ease-in-out infinite;}   /* v3.42 — no more delayed pop-in; the pipe is just THERE, floating gently */   /* floats BESIDE theframe, never in the mouth */
.fr-pipe2 svg{width:100%; height:auto; display:block; filter:drop-shadow(1px 1px 0 #0006) drop-shadow(0 0 2px #ffffff66);}
@keyframes pipeIn{from{opacity:0; transform:translateY(9px) rotate(-4deg)}to{opacity:1; transform:translateY(0) rotate(-4deg)}}
@keyframes pipeFloat{   /* floats + cycles the FULL rainbow, with sharper glitch bursts */
  0%  {transform:translateY(0) rotate(-4deg); filter:hue-rotate(0deg) saturate(1.7)}
  22% {transform:translateY(-5px) rotate(2deg); filter:hue-rotate(90deg) saturate(2.1)}
  44% {transform:translateY(-3px) rotate(4deg); filter:hue-rotate(180deg) saturate(2.3)}
  66% {transform:translateY(-6px) rotate(-2deg); filter:hue-rotate(260deg) saturate(2.1)}
  86% {transform:translateY(-2px) rotate(-4deg); filter:hue-rotate(320deg) saturate(2.5)}
  90% {transform:translateY(-3px) skewX(9deg); filter:hue-rotate(140deg) saturate(3) invert(.15)}   /* GLITCH burst */
  94% {transform:translateY(-4px) skewX(-6deg); filter:hue-rotate(-80deg) saturate(2.7)}
  100%{transform:translateY(0) rotate(-4deg); filter:hue-rotate(360deg) saturate(1.7)}}
.fr-smoke{position:absolute; left:82%; top:auto; bottom:-2%; width:5px; height:5px; background:#cfd6dd; z-index:7; pointer-events:none; opacity:0; animation:frSmoke 3.4s ease-out 10.4s infinite;}   /* smoke curls off the bowl ON the book, up and around the inklet */
.fr-smoke.sk2{animation-delay:11.3s; width:4px; height:4px; background:#bde3f2;}
.fr-smoke.sk3{animation-delay:12.2s; width:6px; height:6px; background:#e6d9f2;}   /* each puff its own colour */
.fr-fog{position:absolute; left:-8%; top:-8%; width:116%; height:116%; z-index:5; pointer-events:none; opacity:0; image-rendering:pixelated;
  background:radial-gradient(circle at 30% 62%, #dfe8ee 0 17%, transparent 18%), radial-gradient(circle at 72% 38%, #cfd6de 0 13%, transparent 14%), radial-gradient(circle at 52% 78%, #bde3f2 0 15%, transparent 16%);
  background-size:44px 44px; animation:fogDrift 8s linear 10.8s infinite;}   /* the 25% pixel FOG hazing around the inklet once the pipe is lit */
.fr-fog.fg2{background-size:30px 30px; animation-duration:12s; animation-direction:reverse; animation-delay:11.6s;}
@keyframes fogDrift{0%{opacity:0; transform:translate(0,4%)}18%{opacity:.25}82%{opacity:.25}100%{opacity:0; transform:translate(6%,-7%)}}
@keyframes frSmoke{0%{opacity:0; transform:translate(0,0)}12%{opacity:.85}100%{opacity:0; transform:translate(5px,-22px) scale(1.9)}}
.fr-mag2{position:absolute; left:4%; top:auto; bottom:3%; width:23%; min-width:11px; z-index:8; pointer-events:none; image-rendering:pixelated; filter:drop-shadow(1px 2px 0 #0006); animation:none; transform:rotate(-6deg);}   /* v3.42 — the reading sweep is RETIRED (it read as the inkling popping out down-right); the glass sits still */   /* the glass STUDIES the case file below */
.fr-mag2 svg{width:100%; height:auto; display:block;}
@keyframes magRead{0%,100%{transform:translate(0,0) rotate(-6deg)}30%{transform:translate(13px,2px) rotate(4deg)}55%{transform:translate(25px,-1px) rotate(8deg)}80%{transform:translate(9px,3px) rotate(-2deg)}}
.fr-book{position:absolute; left:50%; bottom:0; transform:translateX(-50%); width:38%; min-width:17px; z-index:6; pointer-events:none; image-rendering:pixelated; filter:drop-shadow(0 2px 0 #0007);}   /* the CASE FILE under the inklet */
.fr-book svg{width:100%; height:auto; display:block;}
.fr-page{position:absolute; left:50%; top:6%; width:40%; height:66%; background:#fdf6e3; border:1px solid #1b151066; transform-origin:0% 90%; opacity:0; animation:pgTurn 5s ease-in-out infinite;}   /* every 5s a page floats over, slow */
@keyframes pgTurn{0%,70%{opacity:0; transform:rotate(0deg) scaleX(1)}74%{opacity:1; transform:rotate(-6deg) scaleX(.85)}84%{opacity:1; transform:rotate(-16deg) scaleX(.12)}94%{opacity:.9; transform:rotate(-24deg) scaleX(-.8)}100%{opacity:0; transform:rotate(-26deg) scaleX(-1)}}
/* battle top — ONE face, ONE size */
/* ⚠⚠⚠ v0.712 — #bClock IS OUT OF THIS LIST, AND IT HAD TO BE. It was the only selector here not
   scoped under #battleTop, and the countdown now lives on the HEADER REEL — where the whole point
   is that it prints at exactly the size the two usernames ended up at (vsClockFace steps it down
   with them). An `!important` 11px would have outranked that inline size outright and pinned the
   clock at a third face on a row of three matched plates. Same trap as v0.578's hand-copied --nmsz:
   an unscoped rule survives the element moving house, and then quietly wins. */
#battleTop #bYou,#battleTop #bFoe,#battleTop .bhp-cups,#battleTop .bhp-lv{font-family:var(--fontD)!important; font-size:11px!important; letter-spacing:.5px;}
/* the energy bar, ELECTRIFIED — and MAX glows steady */
#efill{position:relative; overflow:hidden;}
#efill::after{content:''; position:absolute; inset:0; background:linear-gradient(105deg, transparent 0 34%, #ffffffcc 46%, #ffffff66 50%, transparent 62%); background-size:230% 100%; animation:eShine 1.3s linear infinite; mix-blend-mode:screen;}
@keyframes eShine{from{background-position:130% 0}to{background-position:-130% 0}}
#ebatNum.max{color:#aee2ff; text-shadow:0 0 5px #7fd4ff, 0 0 12px #bfeaff, 1px 1px 0 #1b1510;}   /* MAX shines BLUE */
/* options squares */
.optrow{display:flex; gap:8px; justify-content:center; margin-top:10px;}
.optsq{width:76px; height:76px; border:3px solid var(--ink); background:var(--paper2); font-family:var(--fontD); font-size:9px; line-height:1.5;
  display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; cursor:pointer; box-shadow:2px 2px 0 #1b151055; color:var(--ink);}
.optsq.on{background:var(--gold);}
.optsq .osq-ic{font-size:20px; line-height:1;}
/* lobby — bigger fighter boxes */
.lobby .lob-art{width:52px; height:54px;}
.lobby .lob-tag{min-height:20px;}
.lob-set{display:flex; align-items:center; justify-content:center; gap:10px; margin:7px 0 3px;}
.modal:has(.lobby){padding:12px 14px; max-width:360px;}   /* the whole lobby fits the safe area — DIP never hides below the fold */
.modal:has(.lobby) h3{margin:0;}
.shoptabs{margin-bottom:2px;}
.fr-prev{width:56px; height:56px; border-radius:50%; background:#fff; border:3px solid var(--ink); margin:2px auto 4px; display:flex; align-items:center; justify-content:center;}
/* ⚠⚠ v0.659 — 70% IS THE INKLET SPRITE'S SIZE INSIDE A PREVIEW, NOT A FRAME'S. This is a DESCENDANT
   selector (0,1,1), so it out-specifies `.fr-24ring{width:104%}` (0,1,0) and was drawing the 24-BIT
   ring at 40.6px where every other frame measures 61–66px — a small ring floating inside the inklet
   instead of sitting on it. The classic white ring escaped only by accident, because it carries an
   inline width. Frame SVGs wear `.frsvg` and are excluded here, the same way they are excluded from
   the sprite-scale rule in css/profile.css. */
.fr-prev svg:not(.frsvg){width:70%; height:70%;}
/* the shop's mini side-reel — REELS + STARS live here now */
#shopSideReel{position:absolute; right:5px; top:12px; z-index:9; display:flex; flex-direction:column; gap:5px;   /* the reel's TOP PIXEL lines up with the pack boxes */
  background:var(--ink); border:2px solid var(--paper); padding:10px 4px; box-shadow:3px 3px 0 #0008;}
#shopSideReel::before,#shopSideReel::after{content:''; position:absolute; left:0; right:0; height:5px;
  background:repeating-linear-gradient(90deg, var(--gold) 0 6px, transparent 6px 13px);}
#shopSideReel::before{top:0;} #shopSideReel::after{bottom:0;}
#shopSideReel .chip{margin:0;}
/* ===== THE SIX NEW FRAMES ===== */
/* ⚠⚠⚠ v0.630 — NO FRAME IS EVER A SQUARE (Odaroo, 2026-08-05). Every ring below used to say
   `border-radius:inherit`, which is round only while the box it sits in happens to be round — in
   the lobby row and on the challenge card it is NOT, so BETA's ring and the neon ring were drawn as
   hard squares. They are all pinned to 50% now. Never write `inherit` on a frame ring again. */
.fr-glA,.fr-glB{position:absolute; inset:-2px; border:3px solid #c93a22; border-radius:50%; pointer-events:none; z-index:4; opacity:.75; animation:frglA 1.8s steps(1) infinite;}
.fr-glB{border-color:#2fa3a0; animation:frglB 2.3s steps(1) infinite;}
@keyframes frglA{0%,80%{transform:translate(0,0)}82%,88%{transform:translate(3px,-2px)}90%,100%{transform:translate(-2px,1px)}}
@keyframes frglB{0%,74%{transform:translate(0,0)}76%,84%{transform:translate(-3px,2px)}86%,100%{transform:translate(2px,-1px)}}
.fr-betaRing{position:absolute; inset:-2px; border:3px dashed #2fa3a0; border-radius:50%; pointer-events:none; z-index:4;}
.fr-betaTag,.fr-alphaTag,.fr-crewTag{position:absolute; right:-8%; bottom:-6%; font-family:var(--fontD); font-size:10px; background:#2fa3a0; color:#fff; padding:1px 5px; z-index:6; pointer-events:none; text-shadow:1px 1px 0 #0008;}
.fr-alphaRing{position:absolute; inset:-2px; border:3px dotted #8b8b8b; border-radius:50%; pointer-events:none; z-index:4; opacity:.9;}
.fr-alphaTag{background:#555;}
.fr-globRibbon{position:absolute; left:50%; transform:translateX(-50%) rotate(-3deg); bottom:-12%; font-family:var(--fontD); font-size:8px; background:#f4c542; color:#1b1510; padding:1px 6px; z-index:6; pointer-events:none; letter-spacing:1px;}
.fr-conf{position:absolute; inset:-4px; border-radius:50%; pointer-events:none; z-index:4;
  background:radial-gradient(circle at 12% 20%, #c93a22 0 1.5px, transparent 2px), radial-gradient(circle at 85% 14%, #2fa3a0 0 1.5px, transparent 2px),
    radial-gradient(circle at 20% 84%, #f4c542 0 1.5px, transparent 2px), radial-gradient(circle at 90% 78%, #c93a22 0 1.5px, transparent 2px);
  animation:frConf 2.4s ease-in-out infinite;}
@keyframes frConf{0%,100%{opacity:.5}50%{opacity:1}}
.fr-crewBand{position:absolute; inset:-2px; border-radius:50%; pointer-events:none; z-index:4;
  border:4px solid transparent; border-image:repeating-linear-gradient(45deg, #f4c542 0 6px, #1b1510 6px 12px) 4;}
.fr-crewTag{background:#c93a22;}
.fr-neonRing{position:absolute; inset:-3px; border:3px solid #ff5fd0; border-radius:50%; pointer-events:none; z-index:4;
  box-shadow:0 0 8px #ff5fd0cc, inset 0 0 6px #ff5fd077; animation:frNeon 1.4s ease-in-out infinite;}
@keyframes frNeon{0%,100%{opacity:1}47%{opacity:1}50%{opacity:.35}53%{opacity:1}72%{opacity:.7}}
/* shop price tags: PLAIN — no gold plate, no black bar, just the number and the icon */
.shoprack .ptag{background:none!important; border:0!important; box-shadow:none!important; padding:1px 0; font-weight:bold;}
.shoprack.legrack{gap:12px;}
.shoprack.legrack .shopcard{transform:scale(1.05); transform-origin:top center;}   /* the legends stand a little TALLER */
/* frames tab: bigger showcase + fat buttons */
/* the COMPACT frame wall — every frame visible at once, names only */
@keyframes fwGl{0%,86%{transform:none; filter:none}88%{transform:translate(1px,-1px) skewX(4deg); filter:hue-rotate(120deg) saturate(1.8)}92%{transform:translate(-1px,0); filter:invert(.14)}96%{transform:none; filter:none}}
.thtile.hi{outline:3px solid var(--gold); animation:pulse 1s ease-in-out 4;}
/* WEARABLES — the floating showcase cloud */
.wfloat{position:relative; height:74px; overflow:hidden; margin:6px 0 2px; border:2px dashed #1b151033;}
.wfloat i{position:absolute; width:34px; height:34px; opacity:.55; animation:wDrift linear infinite;}
.wfloat i svg{width:100%; height:100%;}
@keyframes wDrift{from{transform:translateX(-40px) translateY(var(--wy)) rotate(-6deg)}to{transform:translateX(360px) translateY(var(--wy)) rotate(6deg)}}
/* the PRIZE box announces a big roll — epic/legendary get the marquee treatment */
.rk-prize.rk-special{animation:rkSpec 1.1s ease-in-out infinite; border-color:var(--gold2)!important;}
@keyframes rkSpec{0%,100%{box-shadow:0 0 0 0 #f4c54200; transform:none}50%{box-shadow:0 0 16px 4px #f4c542cc; transform:scale(1.05)}}
/* the ODD GIG wears dotted white pixels — it's the special one */
.mission.oddgig{border:3px dashed #ffffff; outline:2px solid #1b1510; box-shadow:inset 0 0 10px #ffffff33;}
/* today's gig chip = the claim button */
.sday.claimme{cursor:pointer; border-color:var(--gold2)!important; box-shadow:0 0 12px #f4c542aa; animation:pulse 1.1s ease-in-out infinite;}
.sday.claimme:active{transform:scale(.94);}
/* vintage stage life — strolling crowd, neon, searchlights, factory smoke */
.ped{animation:pedWalk linear infinite;}
@keyframes pedWalk{from{transform:translateX(-16%)}to{transform:translateX(112%)}}
.neonPulse{animation:neonP 1.6s ease-in-out infinite;}
@keyframes neonP{0%,100%{opacity:.5}50%{opacity:1}}
.searchL{animation:searchSway 7s ease-in-out infinite alternate;}
.searchR{animation:searchSway 9s ease-in-out infinite alternate-reverse;}
@keyframes searchSway{from{transform:rotate(-10deg)}to{transform:rotate(12deg)}}
.smokes{animation:smokeUp 5s ease-in-out infinite alternate;}
@keyframes smokeUp{from{transform:translateY(0)}to{transform:translateY(-2.5px)}}
/* the hand floats, ready for deployment */
#hand .hcard{animation:handIdle 2.8s ease-in-out infinite;}
@keyframes handIdle{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
#hand .hcard.nope{animation:none;}
/* ============================================================
   ⭐⭐⭐ v0.830 — PLAYING A CARD. See cardPlayFX (js/brawl.js) for the three beats.
   ------------------------------------------------------------
   ⚠⚠ THE CART IN THE HAND SHRINKS WITH `scale`, NOT WITH `transform`. `#hand .hcard` already runs
   the `handIdle` float on `transform`, and `animation`/`transform` is a shorthand — a transform
   here would REPLACE the float rather than compose with it, and the carried card would jump to
   whatever offset the float happened to be at. `scale` is an independent property, so the two
   compose. (Same trap as the vault tile's breath, CLAUDE.md.)
   ⚠ 50% opacity and 44% size are Odaroo's own numbers ("very small, with 50% opacity").
   ============================================================ */
#hand .hcard.dragging{scale:.44; opacity:.5; transition:scale .16s ease-out, opacity .16s ease-out;
  pointer-events:none; filter:grayscale(.4);}
/* the turn itself — one fast 360° at the finger, black back at the halfway mark, into the floor */
.cardPlay{position:fixed; width:54px; height:76px; margin:-38px 0 0 -27px;
  z-index:86; pointer-events:none; perspective:420px;}
.cp-in{width:100%; height:100%; position:relative; transform-style:preserve-3d;
  animation:cpTurn .46s cubic-bezier(.25,.7,.35,1) forwards;}
@keyframes cpTurn{
  0%{transform:rotateY(0) scale(1); opacity:1}
  55%{transform:rotateY(200deg) scale(.72); opacity:1}
  100%{transform:rotateY(360deg) scale(.22) translateY(10px); opacity:0}}
.cp-face{position:absolute; inset:0; backface-visibility:hidden; overflow:hidden;
  border:2px solid #1b1510; border-radius:3px; box-shadow:2px 3px 0 #1b151088;}
.cp-face.cp-f{background:#000;}
.cp-face.cp-b{transform:rotateY(180deg);}
/* ⚠ THE BLACK BACK IS THIS COPY ONLY. The shared `.cardback` is the card's own cream stock and has
   been since v0.790, on purpose — `.cpBlk` re-inks it here because Odaroo asked for "the back of
   the card that is black as you're playing it", and nowhere else. */
.cp-face .cpBlk{position:absolute; inset:0; border:none; background:#000;}
.cp-face .cpBlk .cb-drop rect{fill:#f2e5c9;}
.cp-face .cpBlk .cb-dots b{background:#f2e5c9;}
@media (prefers-reduced-motion:reduce){
  .cp-in{animation-duration:.2s;}
  #hand .hcard.dragging{transition:none;}
}
/* the drop-flip — cards FLIP portrait-style as they land on the field */
.dropFlip{position:fixed; width:54px; height:76px; margin:-38px 0 0 -27px; z-index:85; pointer-events:none; perspective:420px;}
.df-in{width:100%; height:100%; position:relative; transform-style:preserve-3d; animation:dfSpin .55s ease-in forwards;}
@keyframes dfSpin{0%{transform:rotateY(0); opacity:1}100%{transform:rotateY(540deg) scale(.35); opacity:0}}
.df-in .cardback{position:absolute; inset:0;}
/* battle cards — full art, cartridge cut, corner chips only */
.card.bcard{filter:drop-shadow(0 3px 0 rgba(27,21,16,.55)); --bw:3px;}   /* battle carts are tiny — rarity keeps its colour but the frame stays 3px so the art breathes */
.card.bcard .art{margin:0; border:none; flex:1;}
.card.bcard .art svg{width:88%; height:88%;}
.card.bcard .lvl{top:auto; bottom:17px; right:-3px; border-width:2px; font-size:10px; padding:1px 4px; color:var(--ink);}   /* perched above the nickname strip */
.card.bcard .nm{font-size:9px; min-height:14px; padding:2px 1px 1px;}
.card.bcard .cost{font-size:11px; padding:3px 5px;}   /* hand carts are tiny — tag scales down but never clips */
.card.bcard .lvl.rz1{background:var(--paper2);} .card.bcard .lvl.rz2{background:#b9c8dc;}
.card.bcard .lvl.rz3{background:#a8cdc4;} .card.bcard .lvl.rz4{background:var(--gold); box-shadow:0 0 8px #f4c542aa;}
/* THRIFTY IN A JIFFY */
.th-sec{font-family:var(--fontD); font-size:11px; letter-spacing:1px; margin:10px 0 5px; padding-bottom:3px; border-bottom:2px dashed var(--ink);}
.th-sec span{opacity:.55; font-size:8px;}
.thrifty .fgear{background:var(--cream);}

/* ALBUM cards — XE + L#, whole piece, nickname */
.card.acard .art{margin:3px 3px 0; border:none; flex:1;}
.card.acard .art svg{width:86%; height:86%;}
/* ⚠ THE SIZE IS MEASURED, NOT DECLARED — albumNameFit() (js/cards.js) writes --acardNm onto the
   grid so every name on the shelf is one size and none of them is clipped. The 11px here is only
   the value it starts from (and the fallback anywhere the variable never gets set). */
/* ⚠⚠ NO `overflow-wrap:anywhere` HERE, and this is the opposite of the pop-up strip's rule on
   purpose. There, a third line is the right answer; here it BREAKS THE MEASUREMENT — an over-long
   nickname splits mid-word ("COUNTE / SS"), scrollWidth stops exceeding clientWidth, albumNameFit
   sees nothing to shrink, and the plate silently grows a second line that knocks that one cartridge
   out of line with the other 63. The shelf shrinks its type instead. */
.card.acard .nm{font-size:var(--acardNm,11px); min-height:18px; padding:2px 1px;}
.card .lvl.rz1{background:var(--paper2);} .card .lvl.rz2{background:#b9c8dc;}
.card .lvl.rz3{background:#a8cdc4;} .card .lvl.rz4{background:var(--gold); box-shadow:0 0 8px #f4c542aa;}
/* the EXPANDED card */
.xc-cart{position:relative; --bw:4px; --fc:var(--ink); border:var(--bw) solid var(--fc); background:var(--cream); padding:34px 8px 24px;  /* v3.42 — the big stepped corner is BACK, cut to the frame's exact width */
  clip-path:polygon(0 0, calc(100% - 26px) 0, calc(100% - 26px) 18px, 100% 18px, 100% 100%, 0 100%);
  background-image:repeating-linear-gradient(90deg, #1b151016 0 6px, transparent 6px 12px), repeating-linear-gradient(90deg, #1b151016 0 6px, transparent 6px 12px);   /* GRIP ridges — the full top run AND the bottom, right up to the LV chip */
  background-size:calc(100% - 96px) 10px, calc(100% - 76px) 10px;
  background-position:60px 8px, 12px calc(100% - 10px);
  background-repeat:no-repeat, no-repeat;
  filter:drop-shadow(0 3px 0 rgba(27,21,16,.4));}
.xc-cart>.notchv{position:absolute; top:calc(-1*var(--bw)); right:calc(26px - var(--bw)); width:var(--bw); height:18px; background:var(--fc); z-index:4; pointer-events:none;}   /* dossier notch edges — same weight as the frame, flush corner */
.xc-cart>.notchh{position:absolute; top:calc(18px - var(--bw)); right:calc(-1*var(--bw)); width:calc(26px + var(--bw)); height:var(--bw); background:var(--fc); z-index:4; pointer-events:none;}
.xc-cart.xc-r2{--fc:#6e4f2a;}   /* RARE — bronze */
.xc-cart.xc-r3{--fc:#c9ced8;}   /* EPIC — silver */
.xc-cart.xc-r4{--fc:var(--gold);}   /* LEGENDARY — gold */
/* FRAME LAW v3 — the dossier card follows the same rule: one weight (--bw:4px above), colour is the only tell */
.xc-cost{position:absolute; top:0; left:0; font-family:var(--fontD); font-size:17px; display:flex; align-items:center; gap:3px; background:#9fd2ef; color:var(--ink); padding:6px 12px; z-index:3;}   /* blue energy on the dossier too */
.xc-lv{position:absolute; bottom:0; right:0; font-family:var(--fontD); font-size:12px; border:2px solid var(--ink); border-right:0; border-bottom:0; padding:4px 9px; color:var(--ink); z-index:3;}
/* ⚠ v0.791 — `.xc-hist` (the backstory plate) is DELETED. It was the single tallest thing on the
   card and it made the whole view scroll on three of the four fold sizes. The card is art + numbers
   now; the acts' stories still live in CARDS[].hist for the blueprint to use. */
.xrow{display:flex; justify-content:center; gap:13px; font-size:12px; margin:7px 0 0; flex-wrap:wrap; font-family:var(--fontD);}
.xrow .xst b{font-size:14px;}
.statup{font-style:normal; color:var(--blue); font-weight:bold; font-size:11px; margin-left:3px; animation:statUpPop .5s var(--pop);}
@keyframes statUpPop{from{transform:scale(0)}to{transform:scale(1)}}
.inkico{display:inline-block; width:15px; height:15px; vertical-align:-3px; margin-left:3px;}
.inkico svg{width:100%; height:100%; display:block;}
.xc-lv.rz1{background:var(--paper2);} .xc-lv.rz2{background:#b9c8dc;} .xc-lv.rz3{background:#a8cdc4;} .xc-lv.rz4{background:var(--gold);}
/* ⭐⭐⭐ v0.791 — THE CARD VIEW FITS THE SCREEN, AND THE ARTWORK IS WHAT GIVES. (Odaroo, 2026-08-12.)
   Every earlier attempt at this was a ladder of `@media (max-height:…)` rules trimming a font here
   and a margin there until the ONE card being looked at fitted — and the next screen size broke it
   again. The height is MEASURED instead: `xcardFit()` (js/cards.js) asks the window how much room
   there is, subtracts the card with no picture in it at all, and hands the remainder to `.xc-art`.
   ⚠⚠ A PURE-CSS VERSION OF THIS DOES NOT WORK, AND WAS TRIED FIRST. Neither a `vh` formula nor a
   flex column can do it: the room a dialog gets is not a fraction of the screen (the D-pad reserve
   is 301px on the folded screen and 230px on the unfolded one — it depends on the pad's GEOMETRY,
   not on the height), and a flex chain inside an auto-height `.modal` is circular — Chromium
   resolved it against the card's MIN-content and squeezed the art to 56px with 114px going spare.
   ⚠ THE WINDOW IS ONLY ~332px TALL ON THE FOLD 8 FOLDED SCREEN. That is the real budget, and it is
   why nothing decorative may be added back to this card. */
.modal:has(.xcard){padding:12px;}
/* ⚠⚠⚠ v0.794 — `position:relative` IS LOAD-BEARING, IT IS NOT TIDINESS. `.holo` is
   `position:absolute; inset:0` (css/holo.css), so with no positioned ancestor here it resolved
   against `.xc-cart` instead and the black glass covered the ENTIRE card — the name, the cost, the
   stats, the level bar, all of it. That is why the expanded card looked like it had no text on it.
   `.card .art` has always carried the same rule, which is why the booster-pack card was fine.
   ⚠ The box is deliberately SMALLER than the pack card's frame is wide (Odaroo, 2026-08-13) — the
   words underneath it are the point of this view. */
.xc-art{position:relative; overflow:hidden;
  width:min(62%,178px); height:190px; margin:4px auto 6px; border:3px solid var(--ink); background:var(--paper2);
  background-image:radial-gradient(#1b151014 1px, transparent 1.4px); background-size:6px 6px;
  display:flex; align-items:center; justify-content:center;}
/* the title and the origin sit UNDER the glass, the way they do on the booster-pack mat */
.xc-nm{font-size:15px; margin:0 4px; line-height:1.2;}
.xc-from{font-family:var(--fontD); font-size:9.5px; letter-spacing:1px; opacity:.72; margin:2px 4px 3px;}
.xc-art svg{width:76%; height:88%; animation:artZoom .42s var(--pop);}   /* the piece ZOOMS in when the cart opens */
@keyframes artZoom{from{transform:scale(.3); opacity:.3}to{transform:scale(1); opacity:1}}
.xc-btns{display:flex; gap:8px; justify-content:center; margin-top:10px;}
.xc-btns .btn{flex:1; max-width:150px; min-height:44px; width:auto;}
/* ⚠ v0.791 — the ONE height rule left on this card, and it is furniture, not artwork. Even with the
   picture down at its 88px floor the card was 16px too tall for the Fold 8's folded screen, so on a
   short window the frame itself gets thinner. The buttons are scoped through #modalBox because
   css/home.css puts a 46px floor on `.btn.opt` and loads LATER than this file. */
@media (max-height:700px){
  .xc-cart{padding:26px 8px 8px;}
  .xcard .xrow{margin-top:3px;}
  .xcard .xc-btns{margin-top:6px;}
  #modalBox .xc-btns .btn{min-height:38px;}
  .xcard .mbar{margin:3px auto !important;}
  .xc-nm{font-size:13px;}
  .xc-from{font-size:8.5px; margin:1px 4px 2px;}
  .xc-art{margin:2px auto 4px;}
}
/* keyboard arrows light the pad arrows — BOTH pads during minigames */
.gpc-ar.kpress{fill:#f4c542;}
/* screen caption under the reel bar (top right) */
.scr-caption{position:relative; z-index:5; text-align:right; padding:6px 14px 0; font-family:var(--fontD); font-size:11px;}
.scr-caption span{display:block; font-size:8px; opacity:.55;}
/* BUG FINDER — the floating magnifier swarm */
.fr-mag{position:absolute; width:14px; height:14px; z-index:4; pointer-events:none; animation:magFloat 2.4s ease-in-out infinite; image-rendering:pixelated;}
.fr-mag svg{width:100%; height:100%; display:block;}
.fr-mag.m1{top:-7px; left:-5px;}
.fr-mag.m2{top:18%; right:-9px; animation-delay:-.4s;}
.fr-mag.m3{bottom:-6px; left:8%; animation-delay:-.8s;}
.fr-mag.m4{bottom:12%; right:-4px; animation-delay:-1.2s; width:10px; height:10px;}
.fr-mag.m5{top:42%; left:-10px; animation-delay:-1.6s; width:11px; height:11px;}
.fr-mag.m6{top:-9px; right:12%; animation-delay:-2s; width:10px; height:10px;}
@keyframes magFloat{0%,100%{transform:translateY(0) rotate(-14deg)}50%{transform:translateY(-3px) rotate(16deg)}}
/* album card L# rides above the nickname line */
.card.acard .lvl{top:auto; bottom:18px; right:-3px;}   /* sits ON the name strip, flush with the right frame — zero gap */
/* the shiny E · ENERGY badge on the battle bar */
.ebadge{display:flex; flex-direction:column; align-items:center; line-height:1; margin-right:3px; flex:none;}
.e-big{font-family:var(--fontD); font-size:20px; color:var(--gold); text-shadow:1px 1px 0 var(--ink); animation:eShine 2.2s ease-in-out infinite;}
@keyframes eShine{0%,100%{filter:brightness(1)}50%{filter:brightness(1.6) drop-shadow(0 0 7px #f4c542)}}
.e-word{font-family:var(--fontD); font-size:6px; color:var(--cream); letter-spacing:1px; opacity:.85; margin-top:2px;}
/* the expanded card's little E */
.xc-e{font-size:inherit; color:inherit; margin-left:0;}   /* 2E — together, no daylight */
/* WAVE CLEAR — the message itself glitches */
.hwavegl{animation:hwGl .24s steps(2) infinite;}
@keyframes hwGl{0%,100%{filter:none}50%{filter:hue-rotate(140deg) contrast(1.7)}}
/* the ENERGY BAR — edge to edge, thin, 10 segments; the number rides the LEFT end, ★ floats above the right */
#scr-battle #energyRow{position:relative; display:flex; align-items:center; gap:7px; order:2; margin:6px 0 0; padding:4px 10px 4px 8px; width:auto;
  background:#1b1510cc; border-top:2px solid var(--paper); border-bottom:2px solid var(--paper); border-radius:0; pointer-events:auto;}
#scr-battle #ebat{flex:1; width:auto; height:13px; border:2px solid var(--paper); background:#3a322a; position:relative;}   /* +25% */
#scr-battle #ebatNum{font-size:20px; min-width:58px; text-align:center;}   /* +25% — room for M A X */
#scr-battle #superBtn{position:static; width:90px; height:90px; margin:-33px 0 -33px 4px; flex:none; font-size:15px; z-index:2;}   /* +25% — the ★ ring stands proud */   /* the ★ rides the END of the bar — a round button, twin to Ⓐ and Ⓑ */
#superBtn .ssstar{display:flex; width:34px; height:34px; filter:drop-shadow(0 0 4px #f4c54288);}
#superBtn .ssstar svg{width:100%; height:100%; display:block;}
#superBtn .ssn{font-size:12px; margin-left:1px;}
#superBtn .ssr{font-size:11px; letter-spacing:0;}
.ebatWrap{display:flex; flex-direction:column; align-items:center; gap:1px; flex:none;}
#ebatNum{font-family:var(--fontD); font-size:16px; color:var(--gold); text-shadow:1px 1px 0 var(--ink); line-height:1;}
#ebat{position:relative; width:92px; height:18px; border:3px solid var(--paper); background:#3a322a;}
#ebat .ebat-nub{position:absolute; right:-8px; top:3px; width:5px; height:8px; background:var(--paper);}   /* the battery tip */
#ebatDec{font-family:var(--fontD); font-size:9px; color:var(--cream); opacity:.85; line-height:1;}
/* the choose-a-card flip: white drop BACK on one side, the summoned PIECE alone on the other */
.df-face{position:absolute; inset:0; backface-visibility:hidden; background:#fff; border:2px solid #000;
  display:flex; align-items:center; justify-content:center; overflow:hidden;}
.df-face.df-f{transform:rotateY(180deg);}
.df-face.df-f svg{width:82%; height:82%;}
.df-face .cardback{position:absolute; inset:0; border:none;}
/* the skyline loops — twin copies pan forever */
#skyline svg.skyloop{width:200%; animation:skyLoop 85s linear infinite;}
@keyframes skyLoop{to{transform:translateX(-50%)}}
/* DEFEAT / results — the stats, one clean line each */
.endstats{display:flex; flex-direction:column; gap:4px; max-width:258px; margin:10px auto 2px; text-align:left;}
.endstats > div{display:flex; justify-content:space-between; align-items:center; gap:8px; border-bottom:1px dashed #1b151033; padding:3px 2px;}
.endstats span{font-size:11px; letter-spacing:1px; opacity:.65; white-space:nowrap;}
.endstats b{font-family:var(--fontD); font-size:12px; white-space:nowrap;}   /* +208 INK reads in ONE row */
/* troupe switcher buttons ride inline in the album header */
.mtq.trq{position:static; display:inline-flex; align-items:center; justify-content:center; vertical-align:middle; margin:0 2px;}
.trnm{cursor:pointer; text-decoration:underline dotted 2px; text-underline-offset:4px;}   /* tap the name to RENAME the troupe */
/* the DROP ZONE — shown while dragging a card onto the field */
/* ⭐ v0.421 — .25 was almost invisible over the boulevard art; the zone you are meant to aim at
   has to be a thing you can see. */
#dropZone{position:absolute; left:0; right:0; z-index:9; pointer-events:none; display:none; opacity:.62;
  clip-path:polygon(17% 0, 83% 0, 100% 100%, 0 100%);   /* the FIELD in perspective — narrow at the enemy, wide at your feet, a proper trapezoid */
  background:
    repeating-linear-gradient(0deg, #1b151022 0 2px, transparent 2px 26px),
    repeating-linear-gradient(90deg, #1b151022 0 2px, transparent 2px 26px);}
#dropZone.on{display:block;}
/* the WHITE perimeter — the field, drawn plainly. It lies on the floor with the halves. */
#dropZone .dz-frame{position:absolute; inset:0; width:100%; height:100%; pointer-events:none;
  filter:drop-shadow(0 1px 0 #1b1510);}
#dropZone .dz-ok{position:absolute; left:4%; width:42%; top:0; bottom:0; border:3px dashed #2f7d6a; background:#2f7d6a3a; box-sizing:border-box; display:flex; align-items:flex-end; justify-content:center; animation:dzPulse 1.1s ease-in-out infinite;
  transform:rotateX(38deg); transform-origin:50% 100%;}   /* the zone LIES ON THE FLOOR — far edge recedes with the map */
/* ⚠ the labels are DARK on cream, not the mid teal/red they sit beside — crReadable measured the
   old #2f7d6a on #fdf6e3 at 3.6:1, under the 4.5:1 floor every word in this game is held to. */
#dropZone .dz-ok span{font-family:var(--fontD); font-size:8px; color:#1c4f43; background:#fdf6e3f2; padding:2px 6px; margin-bottom:5px;}
#dropZone .dz-no{position:absolute; left:46%; right:0; top:0; bottom:0; box-sizing:border-box;
  background:repeating-linear-gradient(135deg,#c93a2244 0 10px, transparent 10px 20px); border-left:3px solid #c93a22;
  display:flex; align-items:flex-end; justify-content:center;
  transform:rotateX(38deg); transform-origin:50% 100%;}
#dropZone .dz-no span{font-family:var(--fontD); font-size:8px; color:#8f2414; background:#fdf6e3f2; padding:2px 6px; margin-bottom:5px;}
#dropZone.spell .dz-no{display:none;}
#dropZone.spell .dz-ok{left:4%; width:92%; border-color:#d9a232; background:#d9a23215;}
@keyframes dzPulse{50%{background:#2f7d6a33}}
/* CLASSIC WHITE pixel ring + the gift-frame preview */
.fr-pixring{position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:108%; height:108%; z-index:4; pointer-events:none; image-rendering:pixelated;}   /* transform-centred — exact on any disc size */
.fr-plain{border-color:transparent !important;}   /* the smooth ring steps aside — the PIXELS are the frame now */
/* the BETA GIFT banner — 5 seconds or one tap */
#giftBanner{position:fixed; left:50%; bottom:32%; transform:translateX(-50%); z-index:9997; cursor:pointer;
  background:var(--ink); border:3px solid var(--gold); box-shadow:4px 4px 0 #0009, 0 0 18px #d9a23266;
  padding:9px 13px; animation:giftPop .45s var(--pop);}
@keyframes giftPop{from{transform:translateX(-50%) scale(0)}60%{transform:translateX(-50%) scale(1.08)}to{transform:translateX(-50%) scale(1)}}
/* INK PANIC — peek row, death sweep, DEFEAT box */
.hpan.hpk{filter:brightness(.5) saturate(.55); pointer-events:none;}   /* the row on deck, dimmed under the floor */
.hpan.dead{background:#6f6f6f !important; border-color:#454545 !important;}
#hzCombo{position:absolute; left:50%; top:38%; transform:translate(-50%,-50%); z-index:30; pointer-events:none; opacity:0;
  font-family:var(--fontD); font-size:34px; line-height:1.3; text-align:center; color:#ffe9a0;
  text-shadow:3px 3px 0 var(--ink), 0 0 18px #f4c542, 0 0 36px #f4c542;}
#hzCombo.go{animation:comboEpic 1.15s cubic-bezier(.2,1.6,.4,1) forwards;}
@keyframes comboEpic{0%{opacity:0; transform:translate(-50%,-50%) scale(.15) rotate(-10deg)}18%{opacity:1; transform:translate(-50%,-50%) scale(calc(var(--cscale,1.5)*1.18)) rotate(3deg)}34%{transform:translate(-50%,-50%) scale(var(--cscale,1.5)) rotate(0deg)}78%{opacity:1}100%{opacity:0; transform:translate(-50%,-56%) scale(var(--cscale,1.5))}}
#hzShock{position:absolute; left:50%; top:38%; width:34px; height:34px; transform:translate(-50%,-50%); z-index:29; pointer-events:none; border:4px solid #ffe9a0; border-radius:50%; opacity:0; box-shadow:0 0 18px #f4c542aa;}
#hzShock.go{animation:comboShock .75s ease-out forwards;}
@keyframes comboShock{0%{opacity:.95; transform:translate(-50%,-50%) scale(.3)}100%{opacity:0; transform:translate(-50%,-50%) scale(9)}}
#horrorWrap.comboPulse{animation:comboWrap .55s ease-out 1;}
@keyframes comboWrap{30%{filter:brightness(1.4) saturate(1.45)}}
.hpan.dead svg{opacity:.16; filter:grayscale(1);}
.hpan.dead::after{content:''; position:absolute; inset:20%;
  background:linear-gradient(45deg, transparent 44%, #9a9a9a 44% 56%, transparent 56%),
             linear-gradient(-45deg, transparent 44%, #9a9a9a 44% 56%, transparent 56%);}   /* a small GREY x INSIDE each block — repeating, never touching its neighbours */
/* ⚠⚠ v0.624 — THE PEEK ROW DIES TOO. `.hpk` dims the row waiting under the floor to half brightness,
   which on a dead board turned the grey into a darker grey and swallowed its x. The whole point of
   the curtain is that the picture goes uniformly grey, so the dimming is dropped once it is dead. */
.hpan.hpk.dead, .hpan.hpk.clearedO{filter:none;}
/* ⚠⚠⚠ v0.624 — AND THE SLABS. A garbage block is ONE `.hjunk` element spanning its whole rectangle,
   so it gets ONE big x rather than a grid of little ones — the same reading as a panel, at the size
   the object actually is. Its rivets (`::before`/`::after`) are the only pseudo-elements it has, so
   the x has to take `::after` over from them, and every part of the rivet rule must be undone or the
   5px-tall strip it declares wins and the x draws as a dash. */
.hjunk.dead{background:#6f6f6f!important; background-image:none!important; border-color:#454545!important;
  box-shadow:inset 0 3px 0 #ffffff22, inset 0 -5px 0 #00000033!important;}
.hjunk.dead .hj-art{opacity:.16; filter:grayscale(1);}
.hjunk.dead .hj-nm{opacity:.22; color:#3a3a3a; text-shadow:none;}
.hjunk.dead::before{display:none;}
.hjunk.dead::after{content:''; position:absolute; left:14%; right:14%; top:14%; bottom:14%; width:auto; height:auto;
  background:linear-gradient(45deg, transparent 46%, #9a9a9a 46% 54%, transparent 54%),
             linear-gradient(-45deg, transparent 46%, #9a9a9a 46% 54%, transparent 54%);}
.hjunk.clearedO{background:#1f4f4a!important; background-image:none!important; border-color:#123531!important;}
.hjunk.clearedO .hj-art{opacity:.14;}
.hjunk.clearedO::before{display:none;}
.hjunk.clearedO::after{content:''; position:absolute; left:16%; right:16%; top:16%; bottom:16%; width:auto; height:auto;
  background:none; border:3px solid #4fe0c8; border-radius:50%;}
.hzdef-box{width:112px; height:112px; margin:10px auto 2px; border:3px solid var(--ink); background:var(--cream); box-shadow:4px 4px 0 #1b151066; display:flex; align-items:center; justify-content:center;}
.hzdef-box svg{width:88px; height:94px;}
.hzhearts{display:flex; gap:9px; justify-content:center; font-size:24px; color:var(--red); margin-top:7px; text-shadow:2px 2px 0 #1b151044;}
.pxheart{width:18px; height:16px; display:inline-block; margin:0 3px; image-rendering:pixelated; filter:drop-shadow(1px 1px 0 #1b151066);}
.pxheart svg{width:100%; height:100%; display:block;}
.pxheart.off{opacity:.2; filter:grayscale(1);}
.pxheart.fizzle{animation:heartFizzle .62s ease-in forwards;}
@keyframes heartFizzle{0%{transform:scale(1)}40%{transform:scale(1.4) rotate(9deg); filter:brightness(1.7)}100%{transform:scale(.05) translateY(-16px) rotate(-24deg); opacity:0}}
#hzLives{display:flex; justify-content:center; margin-top:3px;}
.hzheart.off{opacity:.15; filter:grayscale(1);}
/* the LV chip pings after a level-up until it's tapped */
/* tiny shop reroll buttons */
.threroll{font-family:var(--fontD); font-size:12px; width:24px; height:24px; padding:0; border:2px solid var(--ink);
  background:var(--paper2); cursor:pointer; vertical-align:middle; margin-left:4px; line-height:1;}
.threroll:active{transform:rotate(180deg);}
/* THRIFTY tabs + picture tiles */
.thtabs{display:flex; gap:4px; justify-content:center; margin:7px 0 6px;}
.thtab{font-family:var(--fontD); font-size:9px; padding:6px 10px; border:2px solid var(--ink); background:var(--paper2); cursor:pointer;}
.thtab.on{background:var(--ink); color:var(--cream);}
.thtab i{display:block; font-style:normal; font-size:7px; opacity:.6; margin-top:2px;}
.thgrid{display:grid; grid-template-columns:repeat(4,1fr); gap:5px;}
/* ⭐ v3.90 — THE MART, SIMPLE: vault strip, two big tabs, one style floor */
.mart-vault{display:flex; justify-content:center; gap:14px; align-items:center; padding:8px 6px 2px; z-index:5; position:relative; flex-wrap:wrap;}
.mart-vault .mv{font-family:var(--fontD); font-size:10px; letter-spacing:1px; color:var(--cream); text-shadow:1px 1px 0 #1b1510; display:inline-flex; align-items:center; gap:4px;}
.mart-vault .mv b{color:var(--gold); font-size:12px;}
.mart-vault .mv svg{width:14px; height:14px;}
.mart-tabs{display:flex; justify-content:center; gap:8px; padding:6px 0 2px; z-index:5; position:relative;}
.marttab{font-family:var(--fontD); font-size:12px; letter-spacing:1px; padding:8px 22px; background:var(--cream); color:var(--ink); border:3px solid var(--ink); border-radius:6px; cursor:pointer; box-shadow:3px 3px 0 #1b151066;}
.marttab i{display:block; font-style:normal; font-family:var(--fontJ); font-size:8px; opacity:.6; margin-top:2px;}
.marttab.on{background:var(--red); color:var(--cream); transform:translateY(1px); box-shadow:1px 1px 0 #1b151066;}
.mart-panel{width:100%; max-width:420px; margin:8px auto 0;}
.mart-subs{margin-bottom:6px;}
@media (max-width:430px){ .mart-panel{max-width:96vw;} }
.thgrid.cosg{grid-template-columns:repeat(3,1fr);}
.thtile{border:2px solid var(--ink); background:var(--cream); padding:5px 2px 4px; text-align:center; cursor:pointer; position:relative;}
.thtile.worn{outline:2px solid var(--gold2);}
.thtile .fgear-art{width:44px; height:44px; margin:0 auto;}
.thtile .fr-prev{margin:0 auto;}
.th-nm{font-size:8px; font-family:var(--fontD); margin-top:3px; line-height:1.25;}
.th-price{display:flex; align-items:center; justify-content:center; gap:3px; font-size:11px; font-weight:bold; margin-top:2px;}
.th-price svg{width:11px; height:11px; display:block;}
.thtile .fgear-tag{position:static; display:inline-block; margin-top:2px;}
/* sunburst backdrop used on several screens */
.sunburst{
  position:absolute; inset:-65%;
  background:repeating-conic-gradient(from 0deg, var(--paper) 0deg 9deg, var(--paper2) 9deg 18deg);
  pointer-events:none;
  /* ⚠⚠⚠ v0.887 — THE ROTATION IS GONE, AND IT IS THE BIGGEST SINGLE THING ON THIS SCREEN.
     Measured by probe-anim-census: this surface is 3,780,230 CSS px — at the Fold 8's DPR 3 that
     is a ~34 MILLION device-pixel texture, continuously animated, and it is the largest
     continuously-animated surface in the game by an order of magnitude. Chrome budgets compositor
     texture per tab and budgets it TIGHTER in fullscreen; when it runs out it evicts the layer and
     re-rasterises on the main thread — the identical mechanism v0.837 caught evicting `#wipe`,
     which reads to the player as the whole screen shifting.
     ⚠⚠ AND NOTHING HERE COULD EVER HAVE MEASURED IT: probe-layers.mjs answers `0 compositor
     layers` because headless Chrome has no GPU (v0.859). Six investigations counted LAYOUT, and
     this screen's LayoutCount is 0/s — it has been 0 since v0.834 and the flicker continued.
     ⚠ A 90s rotation moves 4 degrees a second: NOBODY CAN SEE IT MOVING. It was paying the
     largest texture bill in the game for an animation no player can perceive frame to frame. */
}
@keyframes spinSlow{to{transform:rotate(360deg)}}
.halftone{
  position:absolute; inset:0; pointer-events:none;
  background-image:radial-gradient(#1b15101a 1px, transparent 1.6px);
  background-size:7px 7px;
}
/* film grain + vignette overlay, always on top */
#grain{
  position:absolute; inset:-50px; z-index:90; pointer-events:none;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='0.55'/%3E%3C/svg%3E");
  opacity:.07;
  /* ⚠⚠⚠ v0.887 — THE JITTER IS GONE. This is a FULL-SCREEN overlay at z-index 90, i.e. over every
     pixel of the game, and it was translating +/-14px HORIZONTALLY four times a second, for ever,
     on every screen. That is Odaroo's report almost word for word: "the screen moves left and
     right by itself with nobody touching anything."
     ⚠ The grain TEXTURE is kept — it is the paper look and it costs nothing standing still.
     What is deleted is the movement of a 655,856px surface that sits on top of everything. */
}
@keyframes grain{
  0%{transform:translate(0,0)} 25%{transform:translate(-14px,9px)}
  50%{transform:translate(11px,-12px)} 75%{transform:translate(-7px,-8px)} 100%{transform:translate(0,0)}
}
#vignette{
  position:absolute; inset:0; z-index:89; pointer-events:none;
  box-shadow:inset 0 0 110px #1b151066, inset 0 0 28px #1b151044;
}
/* occasional film scratch */
/* ⚠⚠ v0.832 — IT MOVES ON `translate`, NOT ON `left`. Animating `left` forces a page LAYOUT on
   every frame of the animation, for ever, on every screen — see the long note beside `.inkpng`.
   This one is a 2px decorative scratch and it was costing the same full layout as anything else.
   ⚠ The offsets are in `vw` rather than `%` because a transform percentage resolves against the
   element's OWN box (2px here), not its parent — so `translate:32%` would move it 0.64px. `#app`
   spans the glass, so vw and the old parent-percentages land in the same place. */
#scratch{
  position:absolute; top:0; bottom:0; width:2px; background:#1b151022; z-index:88;
  left:30%; pointer-events:none; opacity:0; animation:scratch 7s linear infinite;
}
/* ⚠⚠ v0.887 — THE SCRATCH NO LONGER TRAVELS. It is a FULL-HEIGHT bar and it was jumping 32vw
   right then 12vw left — on the folded Fold 8 that is a ~180px sideways leap of a floor-to-ceiling
   line, every 7 seconds. It reads exactly like the screen shifting sideways, which is what was
   reported. It blinks in place now: the film-scratch flourish survives, the sideways leap does not.
   ⚠ The `translate` keys are kept at 0 rather than dropped, so nothing else can inherit a stale
   offset from a paused animation. */
@keyframes scratch{
  0%,88%{opacity:0; translate:0} 89%{opacity:.6; translate:0} 91%{opacity:0; translate:0}
  92%{opacity:.5; translate:0} 94%{opacity:0; translate:0} 100%{opacity:0; translate:0}
}
/* ---------------- screens & transitions ---------------- */
.screen{
  position:absolute; inset:0; display:none; flex-direction:column;
  overflow:hidden;
}
.screen.on{display:flex; animation:scrIn .45s var(--pop);}
@keyframes scrIn{from{transform:translateY(14px) ;opacity:.4} to{transform:none;opacity:1}}
/* ============================================================
   ⭐⭐⭐ v0.785 — THE SCREEN CHANGE IS A PERSONA-STYLE SLASH, AND IT COSTS THE PHONE NOTHING.
   (Odaroo, 2026-08-11, filmed on a Fold 8: "whenever it loads in the game it starts glitching…
   either make the actual effect properly, like Persona 5, or completely remove it.")

   ⚠⚠⚠ WHAT WAS ACTUALLY WRONG — IT WAS NOT THE ANIMATION, IT WAS THE RASTERISER. The old wipe was
   a single 40x40 <svg> ink-splat run up to `scale(42)`. A transform on an SVG is NOT a free
   composited transform: the browser re-rasterises the vector at the scale it is being shown at, so
   every screen change asked the phone to redraw a path at roughly 1700x1700 — and it held the
   screen fully black for 0.93s while it did. That black hold is the "glitch" in the video, and the
   D-pad stayed visible throughout because #wipe is z-index 100 and #gpad is 460.

   ⚠⚠ SO THE RULE IS: A TRANSITION MAY ONLY MOVE PLAIN BOXES. Every element here is a solid-colour
   <i> and the ONLY animated property is `transform` (translate3d + a constant skew), which the
   compositor runs on its own thread. No clip-path animation (not composited), no filter, no
   opacity fade on a full-screen layer, no SVG, no canvas. If a future flourish cannot be expressed
   as a moving rectangle, it does not belong on a screen change.

   ⚠ THE TRAVEL IS IN `vw`, NEVER `%`. A percentage translate resolves against the ELEMENT'S OWN
   width, so the 120%-wide sheet and the 6%-wide accent bar would move wildly different distances
   and the accents would never leave their boxes.

   ⚠⚠ .42s TOTAL, AND THE COVER IS 40%-70% — 168ms to 294ms. THE HOLD IS SIZED BY WHAT `swap()`
   COSTS, NOT BY TASTE. Measured with tools/test-transition.mjs: js/ui.js fires its swap timer at
   120ms, but swap() itself does ~90ms of main-thread work (it clears the modal stack, banks the
   play counters, saves, remounts the HUD) before the new screen is painted — so the picture does
   not actually change until ~215ms. A 60ms hold, which is all the eye needs, therefore uncovered
   the screen BEFORE the change it was there to hide. The hold has to outlast swap().
   ⚠ Do not "tighten" this back up without re-running that test. The old wipe hid the same 90ms
   inside a 930ms black-out, which is why nobody had to think about it — and why it looked broken.
   ⚠ `visibility` is what parks it, not `display` — a display change would rebuild the layer on
   every screen change, which is the cost this whole block exists to avoid.
   ============================================================ */
#wipe{position:absolute; inset:0; z-index:100; pointer-events:none; overflow:hidden; visibility:hidden;}
#wipe.go{visibility:visible;}
/* ⭐⭐⭐ v0.837 — `will-change` IS ARMED ONLY WHILE THE SLASH IS RUNNING. THIS IS THE CHROME-
   FULLSCREEN BLACK STALL. (Odaroo, 2026-08-17, filmed on a Fold 8 in Chrome fullscreen: a screen
   change holds a near-black screen with only the D-pad on it for ~2 seconds. Samsung Internet is
   fine.)
   `will-change:transform` is not a hint, it is an instruction to promote the element to its own
   compositor layer AND KEEP IT THERE. These three <i> are 120vw x 140vh each, so on the unfolded
   Fold 8 at DPR 3 that is three textures of roughly 1848x3427 device pixels — about 75MB of GPU
   memory, held for the entire life of the page, for an element that is `visibility:hidden` more
   than 99% of the time. Chrome on Android budgets that memory per tab and it budgets it TIGHTER
   in fullscreen, where the compositor is already carrying a bigger root surface; when it runs out
   it evicts the layer and re-rasterises on the main thread — which is a stuck black sheet, with
   #gpad (z-index 460) still visible above #wipe (z-index 100). That is the exact signature v0.785
   describes, arriving from the other direction: v0.785 removed the SVG that was being re-rasterised
   and left behind the permanent promotion that makes the phone run out of room to composite.
   ⚠⚠ THE PROMOTION IS STILL THERE FOR THE 0.42s IT MATTERS — `.go` turns it on, and `.go` is added
   one forced reflow before the animation starts (js/ui.js, go()), so the layer exists before the
   first frame. Nothing about the animation gets slower; only the idle cost goes away.
   ⚠ Do not "simplify" this back onto the base rule. A permanent will-change on a full-screen box
   is the largest single compositor cost in this game and it buys nothing while the box is hidden. */
#wipe i{position:absolute; top:-20%; height:140%; display:block;
  transform:translate3d(130vw,0,0) skewX(-14deg);}
#wipe.go i{will-change:transform;}
/* the black sheet — spans -10%..110% so a 130vw travel clears the screen at BOTH ends */
#wipe .w-sheet{left:-10%; width:120%; background:var(--ink);}
/* two accent bars, positioned to the LEFT of the sheet so they cross the screen just AHEAD of it */
#wipe .w-a{left:-24%; width:6%; background:var(--red,#c93a22);}
#wipe .w-b{left:-33%; width:3.5%; background:var(--gold,#ffd24a);}
#wipe.go i{animation:wipeSlash .42s cubic-bezier(.4,0,.2,1) both;}
@keyframes wipeSlash{
  0%{transform:translate3d(130vw,0,0) skewX(-14deg)}
  40%,70%{transform:translate3d(0,0,0) skewX(-14deg)}
  100%{transform:translate3d(-130vw,0,0) skewX(-14deg)}
}
/* ⚠ Somebody who has asked the OS for less motion gets a straight cut: no slash, no black hold. */
@media (prefers-reduced-motion:reduce){ #wipe.go i{animation-duration:.01s;} }
/* ---------------- typography ---------------- */
h1,h2,h3,.disp{font-family:var(--fontD); font-weight:400; letter-spacing:.5px;}
.title-xl{font-size:54px; line-height:.95; text-align:center; color:var(--ink);
  text-shadow:3px 3px 0 var(--paper3), 5px 5px 0 var(--ink);}
.subtitle{letter-spacing:3px; text-transform:uppercase; font-size:15px; text-align:center;}
/* ---------------- buttons ---------------- */
.btn{
  font-family:var(--fontD); font-size:18px; color:var(--cream);
  background:var(--ink); border:3px solid var(--ink);
  padding:12px 26px; cursor:pointer; position:relative;
  transform:skewX(-6deg); transition:all .15s var(--pop);
  box-shadow:4px 4px 0 #1b151088;
}
.btn>span{display:inline-block; transform:skewX(6deg);}
.btn:hover{transform:skewX(-6deg) scale(1.05) rotate(-1deg); background:var(--red); border-color:var(--ink);}
.btn:active{transform:skewX(-6deg) scale(.93); box-shadow:1px 1px 0 #1b151088;}
.btn.red{background:var(--red);} .btn.red:hover{background:var(--ink);}
.btn.gold{background:var(--gold); color:var(--ink);} .btn.gold:hover{background:var(--red); color:var(--cream);}
.btn.ghost{background:transparent; color:var(--ink);}
.btn.ghost:hover{background:var(--ink); color:var(--cream);}
.btn:disabled{opacity:.45; pointer-events:none;}
.btn.sm{font-size:17px; padding:7px 14px; box-shadow:3px 3px 0 #1b151066;}
/* ---------------- top bar ---------------- */
.topbar{
  position:relative; z-index:10; display:flex; align-items:center; gap:8px;
  padding:var(--hudbar-pt) 12px var(--hudbar-pb); background:var(--ink);   /* v0.613 — symmetric, and the SAME vars --filmbar-h is built from */
  border-bottom:3px solid var(--ink);   /* round bulbs retired — the SQUARE film holes carry the bar now */
  clip-path:polygon(0 0,100% 0,100% calc(100% - 7px),96% 100%,0 100%);
}
.topbar>*{position:relative; z-index:1;}
/* ============================================================
   ⭐⭐⭐ v0.650 — ONE HUD, EVERY SCREEN: `.hudreel` ON TOP, `.subbar` UNDERNEATH.
   (Odaroo, 2026-08-05: "the numbers are shortened on one screen. I want the main HUD to be like on
   the main menu, where you see the whole name, and it's resized and always centred. Make this game
   HUD the main top for the profile, for Mart, for album and gigs — the same seamless HUD when you
   go from one screen to another, always the same size, always the same stuff.")

   ⚠⚠⚠ THE REASON THE NAME WAS SHORTER ON GIGS WAS THAT GIGS WAS NOT WEARING THIS BAR AT ALL. Home
   and the badge wall rendered hudHTML(); everything else got `mountScreenHud`'s *mini* row, whose
   name was a bare `.hud-name` with `max-width:112px` and `text-overflow:ellipsis` written straight
   into the markup — and because that span had no inner `<span>`, hudNameFit's shrink loop skipped
   it, so it could only ever clip. Six screens, three different top bars, three different answers.
   There is ONE now: every one of those screens carries `<div class="topbar hudreel">` filled by
   hudHTML(), so the portrait, the name, its size, the shrink-rather-clip rule and the two chips are
   the same object on all of them — identical by construction, not by three rules agreeing.

   ⚠⚠ THE SELECTORS ARE A CLASS, NOT AN ID LIST. They used to read `#hudHome,#hudAch,#hudPacks`,
   repeated across three stylesheets, and the ONLY way to give a new screen the reel was to find
   every one of those lists and add an id. That is precisely how the Mart drifted (it was on the
   chip/size lists but not on the film-reel list in css/menu.css, so it had the spacing of the reel
   and none of the sprockets). Adding the class is now the whole job.

   ⚠ WHAT A SCREEN WANTS TO SAY FOR ITSELF GOES IN `.subbar`, NEVER IN THE REEL. BACK, the screen's
   title, the Mart's purse row — all of it lives in the second black row. The reel is not negotiable
   per screen; that is what makes moving between screens seamless.
   ⚠ IT IS THE SAME BLACK AND SITS FLUSH, so the two rows read as one bar. The bottom notch
   (clip-path) belongs to whichever row is LAST — the reel has `clip-path:none` (css/menu.css), so
   when a subbar follows it the notch is on the subbar, and on Home, where nothing follows, the
   reel's own sprockets finish the bar.
   ============================================================ */
.subbar{
  position:relative; z-index:9; display:flex; flex-direction:column; align-items:stretch;
  gap:calc(6px * var(--gs,1)); background:var(--ink);
  padding:calc(5px * var(--gs,1)) calc(12px * var(--gs,1)) calc(8px * var(--gs,1));
  border-bottom:3px solid var(--ink);
  clip-path:polygon(0 0,100% 0,100% calc(100% - 7px),96% 100%,0 100%);
}
.subbar>*{position:relative; z-index:1;}
.subbar-top{display:flex; align-items:center; gap:8px; width:100%;}
.subbar .bar-title{margin-left:auto; min-width:0; flex:0 1 auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px!important;}
/* ============================================================
   ⭐⭐⭐ v0.658 — THE SCREEN SIGN: a little theatre marquee with chasing bulbs and neon.
   (Odaroo, 2026-08-05.) Built by inkSignHTML() in js/ui.js — read the note there for why the bulb
   order is a lap and why the animation delay is NEGATIVE.

   ⚠⚠ `overflow` MUST BE VISIBLE ON THE TITLE SLOT. `.bar-title` above is `overflow:hidden` with an
   ellipsis, which is right for a word and fatal for this: the bulbs sit on the sign's OUTSIDE edge
   (top:-3px and friends), so a hidden parent shaves the top and bottom rows clean off and leaves a
   sign lit only down its sides. Same trap on `.st-bar`, which is a skewed flex row.
   ⚠ THE SIGN NEVER SHRINKS THE ROW IT IS ON: it is `flex:none` and its words never wrap, so the
   marquee is the same size on both folds and the bar's height is the sign's height plus its padding.
   ============================================================ */
/* ⚠ BOTH SELECTORS ON PURPOSE: the four static screens carry `data-sign` (no `:has()` needed, so
   the rule holds on an engine without it), and the Mart's row is rewritten from JS with a plain
   `.bar-title`, which only `:has()` can catch. */
.subbar .bar-title[data-sign]{overflow:visible; text-overflow:clip; flex:none; margin-right:0;}
.subbar .bar-title[data-sign],
.subbar .bar-title:has(.inkmq){overflow:visible; text-overflow:clip; flex:none; margin-right:0;}
/* ⚠⚠ `margin-right:0` IS NOT TIDINESS — there is a bare `.bar-title{margin-right:32px}` in
   css/menu.css, left from when the save/cog buttons sat to the right of every title. It parked the
   sign 32px in from the edge while the film reel above it ran flush, so the two rows visibly did not
   line up. These selectors out-specify it; do not "simplify" them back to a single class. */
.inkmq{position:relative; display:inline-flex; align-items:center; justify-content:center; flex:none;
  padding:6px 17px; margin:3px 4px; background:#150710; border:2px solid #ffd76a; border-radius:3px;
  font-family:var(--fontD);
  box-shadow:0 0 0 2px #1b1510, 0 0 11px #ff4d8d99, inset 0 0 13px #ff4d8d33;}
.mq-txt{display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1;}
.mq-txt b{font-size:13px; letter-spacing:2px; white-space:nowrap; color:#fff6e0;
  text-shadow:0 0 5px #ff4d8d, 0 0 11px #ff4d8daa, 0 0 18px #ff4d8d66;}
.mq-txt em{margin-top:3px; font-style:normal; font-size:8px; letter-spacing:2px; white-space:nowrap;
  color:#ffbcd6; text-shadow:0 0 5px #ff4d8d88;}
.mq-row,.mq-col{position:absolute; display:flex; pointer-events:none;}
.mq-row{left:9px; right:9px; justify-content:space-between;}
.mq-t{top:-4px;}
.mq-b{bottom:-4px; flex-direction:row-reverse;}          /* ⚠ reversed — the chase is one lap */
.mq-col{top:9px; bottom:9px; flex-direction:column; justify-content:space-between;}
.mq-r{right:-4px;}
.mq-l{left:-4px; flex-direction:column-reverse;}         /* ⚠ reversed — the chase is one lap */
.inkmq i{width:5px; height:5px; border-radius:50%; flex:none; background:#7a4a2c;
  box-shadow:0 0 0 1px #1b1510;
  animation:mqBulb 1.6s linear infinite; animation-delay:calc(var(--i,0) * -.066s);}
@keyframes mqBulb{
  0%,20%{background:#fff0b8; box-shadow:0 0 0 1px #1b1510, 0 0 7px 2px #ffd76acc;}
  30%,100%{background:#7a4a2c; box-shadow:0 0 0 1px #1b1510;}
}
@media (max-width:430px){
  .inkmq{padding:5px 12px; margin:2px 2px;}
  .mq-txt b{font-size:11px; letter-spacing:1px;}
  .mq-txt em{font-size:7px; margin-top:2px;}
  .inkmq i{width:4px; height:4px;}
  .mq-t{top:-3px;} .mq-b{bottom:-3px;} .mq-r{right:-3px;} .mq-l{left:-3px;}
}
@media (prefers-reduced-motion:reduce){ .inkmq i{animation:none; background:#ffe9a8;} }
.subbar .backbtn{padding:3px 11px; font-size:15px; flex:none;}
@media (max-width:430px){
  .subbar{padding:calc(4px * var(--gs,1)) calc(9px * var(--gs,1)) calc(7px * var(--gs,1));}
  .subbar .backbtn{padding:2px 8px; font-size:12px;}
  .subbar .bar-title{font-size:11px!important; letter-spacing:0;}
}
.chip{
  display:flex; align-items:center; gap:5px;
  background:var(--paper); border:2px solid var(--ink);
  padding:3px 10px 3px 6px; font-size:18px; font-weight:bold;
  transform:skewX(-8deg); box-shadow:2px 2px 0 #00000055;
  white-space:nowrap;
}
.chip>*{transform:skewX(8deg);}
.chip svg{width:17px;height:17px;flex:none;}
/* persistent mini resource HUD injected into every screen's topbar */
/* full HUD (home/shop) — tighten spacing so avatar+name+3 chips fit 360px wide */
.hudreel{gap:4px;}
/* ⚠⚠ v0.611 — THE HEIGHT IS `--hudchip-h` AND THAT IS LOAD-BEARING, NOT TIDINESS. This chip is what
   decides how thick the whole home reel is, and `--filmbar-h` (top of this file) is the expression
   that says so. Writing a literal here again would let the two disagree silently, which is exactly
   how the connecting strip spent forty builds at half the bar's thickness. */
.hudreel .chip{padding:0 6px; height:var(--hudchip-h); min-height:var(--hudchip-h); font-size:14px; align-items:center;}   /* uniform box height that comfortably holds the numbers */
.hudreel .chip svg{width:22px; height:22px;}   /* ink + reel + all currency icons noticeably bigger */
.hudreel .namechip{font-size:13px; letter-spacing:1px;}
/* save+cog left the ticker → reel + card chips now fit on phones. Only the very smallest screens drop card. */
/* phones: tighten all five chips so name+ink+reel+cup+card fit without save/cog taking room */
@media (max-width:430px){
  /* ⚠ v0.611 — the chip steps down here, so `--filmbar-h` steps with it and the connecting strip
     follows the fold without a second breakpoint of its own. Override the VAR, never the height. */
  /* v0.641 — the SHOWCASE steps to 88 folded; --inkav, the corner size, is left exactly where v0.640
     measured it. The folded value being the LARGER of the two is deliberate and predates this: the
     folded phone scales the whole HUD up.
     ⚠⚠ 88, NOT THE 100 THE UNFOLDED +23 WOULD SUGGEST, AND THE REASON IS THE USERNAME. The bar
     reserves the lens's full width on its left, so every pixel the portrait takes comes off the room
     hudNameFit has to print the name in. Measured on the folded bar with "ODAROO": 78px lens → a
     10px name, 88 → 9px, 92 → 8.5px, 100 → 8px. The name is already being shrunk hard at 375px wide
     (--nmsz asks for 17), and v0.613 raised it on purpose because Odaroo wants it read FIRST — so
     the showcase takes the biggest step that costs the name one point rather than two. */
  :root{ --hudchip-h:25px; --inkav:78px; --inkav-lg:88px; }   /* v0.640 — the folded lens is BIGGER, and the VS corners follow it now */
  /* ⚠ v0.641 — DERIVED, NOT 100px. The room the bar has to leave for the portrait is the portrait's
     own width plus its inset, so it follows --inkav on its own. A constant here was fine while the
     lens never moved; it silently lands the username ON the inklet the moment --inkav grows. */
  .hudreel{padding-left:calc(8px + var(--inkav-lg,92px) + 8px); gap:2px;}
  .hudreel .chip{padding:0 4px; font-size:11px;}
  .hudreel .chip svg{width:18px; height:18px;}
  .hudreel .namechip{max-width:58px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:inline-block; line-height:23px;}
  .hud-av{width:42px; height:42px;}
  .hud-av.hud-av-lg{width:var(--inkav,78px); height:var(--inkav,78px);}
}
@media (max-width:339px){ .hudreel .chip.cardchip{display:none;} }
.hudmini{margin-left:auto; display:flex; gap:4px; align-items:center; flex:none;}
/* save + cog, inline in the ticker (straight line with the rest of the HUD) */
.hudctrls{display:flex; gap:5px; align-items:center; flex:none; margin-left:auto;}
.hudmini .hudctrls{margin-left:4px;}
.hudbtn{background:none; border:0; padding:0; margin:0; cursor:pointer; color:var(--cream); width:23px; height:23px; line-height:0; flex:none;}
.hudbtn svg{width:100%; height:100%; display:block;}
.hudbtn:active{filter:invert(1);}
#saveBtn,#pauseBtn{display:none!important;}   /* retired the floating buttons — now inside the ticker */
.topbar .bar-title{min-width:0; flex:0 1 auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px!important;}   /* full title (only ellipsis if truly no room) */
.topbar .btn.sm{padding:3px 9px; font-size:12px;}          /* compact back/reel buttons so titles never clip */
.topbar .backbtn{padding:3px 9px; font-size:13px;}
.hudbtn{width:21px; height:21px;}
@media (max-width:360px){   /* the narrowest phones — tighten the ticker so titles still never clip */
  .topbar .bar-title{font-size:10px!important; letter-spacing:0;}
  .topbar .btn.sm{padding:2px 6px; font-size:10px;}
  .topbar .backbtn{padding:2px 7px; font-size:11px;}
  .hudbtn{width:19px; height:19px;}
  .hudctrls{gap:3px;}
}
/* battle keeps a slim ink+cups HUD alongside the names/clock */
#battleTop{gap:6px;}
#battleTop .vsname{flex:1 1 0; min-width:0; max-width:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px;}   /* full name fits — no more ODAR… / WAVE… */
#battleTop #bYou{text-align:left;}
#battleTop #bFoe{text-align:right;}
#battleTop #bClock{flex:none;}
#battleTop .hudmini-b{margin-left:4px;}
#battleTop .hudmini-b .chip.mini{font-size:10px; padding:1px 5px 1px 4px;}
.chip.mini{font-size:11px; padding:2px 7px 2px 5px; gap:3px; box-shadow:1px 1px 0 #00000055;}
.chip.mini svg{width:13px; height:13px;}
@media (max-width:360px){ .chip.mini{font-size:10px; padding:2px 5px 2px 4px;} .chip.mini svg{width:11px;height:11px;} }
.backbtn{
  font-family:var(--fontD); background:var(--red); color:var(--cream);
  border:2px solid var(--paper); font-size:19px; padding:4px 13px;
  cursor:pointer; transform:skewX(-8deg); transition:all .15s var(--pop);
}
.backbtn:hover{transform:skewX(-8deg) scale(1.08);}
.bar-title{font-family:var(--fontD); color:var(--cream); font-size:17px; margin-left:auto; letter-spacing:1px;}
/* ---------------- home menu ---------------- */
.menu-stack{display:flex; flex-direction:column; gap:10px; padding:10px 20px 6px; position:relative; z-index:5;
  flex:1; min-height:0; overflow-y:auto;}
.menu-stack::-webkit-scrollbar{width:4px} .menu-stack::-webkit-scrollbar-thumb{background:var(--ink)}
.menu-card{
  position:relative; display:flex; align-items:center; gap:14px;
  background:var(--cream); border:3px solid var(--ink);
  padding:9px 14px; cursor:pointer;
  box-shadow:5px 5px 0 var(--ink);
  transition:all .18s var(--pop); overflow:visible;
}
.menu-card:nth-child(odd){transform:rotate(-1.2deg) skewX(-2deg);}
.menu-card:nth-child(even){transform:rotate(1deg) skewX(-2deg);}
.menu-card:hover{transform:rotate(0deg) skewX(-2deg) translateX(7px) scale(1.03); background:var(--red); color:var(--cream);}
.menu-card:hover .menu-sub{color:var(--paper2);}
.menu-card:active{transform:scale(.96);}
.menu-art{width:30px;height:30px;flex:none;}
.menu-card:nth-child(2) .menu-art{animation-delay:.4s}
.menu-card:nth-child(3) .menu-art{animation-delay:.8s}
.menu-card:nth-child(4) .menu-art{animation-delay:1.2s}
.menu-card:nth-child(5) .menu-art{animation-delay:1.6s}
.menu-label{font-family:var(--fontD); font-size:18px; line-height:1;}
.menu-sub{font-size:14px; letter-spacing:1px; color:#1b151099; margin-top:3px; text-transform:uppercase;}
.menu-badge{
  position:absolute; top:-10px; right:-8px; background:var(--red); color:var(--cream);
  font-family:var(--fontD); font-size:17px; min-width:24px; height:24px; line-height:21px;
  text-align:center; border:2px solid var(--ink); border-radius:50%;
  animation:pulse 1.4s ease-in-out infinite; padding:0 5px;
}
@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.18)}}
@keyframes bob{0%,100%{transform:translateY(0) rotate(-3deg)}50%{transform:translateY(-5px) rotate(3deg)}}
@keyframes bob2{0%,100%{transform:translateY(0) scaleX(1)}30%{transform:translateY(-7px) scaleX(.96)}60%{transform:translateY(0) scaleX(1.05)}}
/* ---------------- cards ---------------- */
.card{
  position:relative; aspect-ratio:5/7; background:var(--cream);
  --bw:3px; --fc:var(--ink);   /* v3.42 — frame weight + colour by rarity, and the GB notch is BACK, cut to the exact border width */
  border:var(--bw) solid var(--fc);
  clip-path:polygon(0 0, calc(100% - 12px) 0, calc(100% - 12px) 10px, 100% 10px, 100% 100%, 0 100%);
  box-shadow:none; filter:drop-shadow(0 3px 0 rgba(27,21,16,.5));   /* drop-shadow follows the notch cut — no ghost pixels */
  cursor:pointer; transition:transform .18s var(--pop);
  display:flex; flex-direction:column; overflow:hidden;
}
.card::before{content:''; position:absolute; top:calc(-1*var(--bw)); right:calc(12px - var(--bw)); width:var(--bw); height:10px; background:var(--fc); z-index:4; pointer-events:none;}   /* the notch's vertical edge — SAME weight as the frame */
.card::after{content:''; position:absolute; top:calc(10px - var(--bw)); right:calc(-1*var(--bw)); width:calc(12px + var(--bw)); height:var(--bw); background:var(--fc); z-index:4; pointer-events:none;}   /* the notch's horizontal edge — meets the vertical flush, no missing pixels */
.redE .cost{background:#9fd2ef !important;}   /* energy reads BLUE everywhere now */
.card{background-image:repeating-linear-gradient(90deg, #1b151016 0 4px, transparent 4px 8px);   /* grip ridges — from the 2E all the way to the notch, like the dossier */
  background-size:calc(100% - 62px) 5px; background-position:38px 3px; background-repeat:no-repeat;}
.card.acard,.card.bcard{background-image:repeating-linear-gradient(90deg, #1b151016 0 4px, transparent 4px 8px), repeating-linear-gradient(90deg, #1b151016 0 4px, transparent 4px 8px);
  background-size:calc(100% - 62px) 5px, calc(100% - 52px) 5px;
  background-position:38px 3px, 8px calc(100% - 25px);
  background-repeat:no-repeat, no-repeat;}   /* the bottom run too — dots up to the L# box */
.card:hover{transform:rotate(-2deg) scale(1.06); z-index:5;}
.card .art{
  flex:1; position:relative; margin:5px 5px 0; border:2px solid var(--ink);
  background:var(--paper2);
  background-image:radial-gradient(#1b151014 1px, transparent 1.4px); background-size:6px 6px;
  display:flex; align-items:center; justify-content:center; overflow:hidden;
}
.card .art svg{width:76%; height:76%;}
.card .nm{
  font-family:var(--fontD); text-align:center; font-size:15px; padding:4px 2px 2px;
  line-height:1.05; min-height:24px; display:flex;align-items:center;justify-content:center;
}
.card .stats{
  display:flex; justify-content:space-around; font-size:15px; font-weight:bold;
  padding:0 4px 5px; align-items:center;
}
.cost{
  position:absolute; top:0; left:0; z-index:3;
  display:flex; align-items:center; justify-content:center; gap:0;
  color:var(--ink); font-family:var(--fontD); font-size:14px; line-height:1;
  background:#9fd2ef; padding:4px 8px; border:2px solid var(--ink); border-left:0; border-top:0;
}   /* the BLUE energy tag — a friendly light blue square, just the digit */
.cost .ce2{font-style:normal; font-size:inherit; color:inherit; margin-left:1px;}
.rar{
  font-size:12px; letter-spacing:1px; text-align:center; text-transform:uppercase;
  padding:2px 1px 3px; border-top:2px solid var(--ink); font-weight:bold; white-space:nowrap; overflow:hidden;
}
.r4 .rar{font-size:10.5px;}   /* v3.112 — LEGENDARY is the longest word on the wall; it always fits now */
.pk-big .rar{font-size:9.5px; letter-spacing:.5px;}
@media (max-width:400px){ .pk-big .rar{font-size:8.5px; letter-spacing:0;} }
.r1 .rar{background:var(--paper2);} .r2 .rar{background:#b9c8dc;}
.r3 .rar{background:#a8cdc4;} .r4 .rar{background:var(--gold); position:relative; overflow:hidden;}
.r4 .rar::after, .card.r4 .art::after{
  content:""; position:absolute; top:-150%; left:-60%; width:38%; height:400%;
  background:#fff8e766; transform:rotate(22deg); animation:shine 2.8s ease-in-out infinite;
}
/* ⚠⚠ v0.834 — a sweep rides `translate`, never `left`. See the rinkSheen note in menu.css:
   `left` is a LAYOUT property and an infinite sweep on it re-solves the whole page every frame.
   ⚠ The travel is expressed against the element's OWN box, not the parent's. */
/* ⚠⚠ `translate`, NOT `transform` — `transform:rotate(22deg)` above owns the shorthand and a
   keyframe on it would REPLACE the tilt and stand the gleam upright.
   travel: left -60% -> 160% = 220% of the PARENT; the bar is `width:38%` of that parent, so in
   its own box that is 220/38 = 579%. */
@keyframes shine{0%,55%{translate:0}85%,100%{translate:579%}}
/* FRAME LAW v3 — rarity = frame COLOUR ONLY. Every card keeps the SAME --bw (3px) and therefore the exact
   same border weight, notch geometry and corner step as a common card. (Before, each rarity had its own
   weight — 4/5/6px — which made the fixed 10px notch read as a different, thinner-looking corner on the
   gold and silver cards. Same thickness, same layout, just different colours.) */
.card.r2{--fc:#6e4f2a;}   /* RARE — dark bronze, matte */
.card.r3{--fc:#c9ced8;}   /* EPIC — silver */
.card.r4{--fc:var(--gold);}   /* LEGENDARY — gold */
.lvl{
  position:absolute; top:auto; bottom:24px; right:3px; background:var(--paper); border:2px solid var(--ink);
  font-size:12px; font-weight:bold; padding:1px 5px; z-index:3;   /* bottom-right, perched ABOVE the rarity, evenly inset */
}
.cnt{position:absolute; bottom:34px; right:4px; background:var(--ink); color:var(--cream);
  font-size:13px; padding:1px 6px; border-radius:8px; z-index:3;}
/* ⭐⭐⭐ v0.839 — THE DUPE BAR. A spare copy is never the words "dupe +1": it is an energy bar
   filling behind "8/12" — carts banked / carts this level costs (DUPE_NEED, js/core.js).
   ⚠ The number sits OVER the fill, so the fill is an <i> at z0 and the figure a <b> above it;
   a bar drawn beside the number would have to shrink the number to fit a 92px cartridge.
   ⚠ The fill grows on `width` because it moves only when a card is COLLECTED — it is not a
   per-frame write, so the v0.834 no-width rule (a bar emptied every frame) does not apply. */
.dbar{position:relative; margin:3px 5px 0; height:13px; z-index:3; flex:none;
  background:var(--ink); border:2px solid var(--ink); border-radius:8px; overflow:hidden;
  display:flex; align-items:center; justify-content:center;}
.dbar.hollow{visibility:hidden;}
.dbar>i{position:absolute; left:0; top:0; bottom:0; background:var(--teal); transition:width .45s var(--pop);}
.dbar>b{position:relative; font-size:10px; letter-spacing:.5px; color:var(--cream);
  text-shadow:1px 1px 0 rgba(0,0,0,.65);}
.dbar.rdy>i{background:var(--gold);}
.dbar.rdy>b{color:var(--ink); text-shadow:none;}
.dbar.max>i{background:var(--gold);}
.dbar.max>b{color:var(--ink); text-shadow:none;}
/* the pack rip / haul cards: the bar sits in the stat row, in flow, wider and taller */
.stats .dbar{position:relative; left:auto; right:auto; bottom:auto; width:100%; height:15px;}
.stats .dbar>b{font-size:11px;}
.card.locked .art svg{filter:brightness(.12) grayscale(1) opacity(.55);}   /* dark SILHOUETTE of the mystery card */
.card.locked{background:var(--paper3);}
.card.locked .lockq{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; z-index:4; pointer-events:none;
  font-family:var(--fontD); font-size:44px; color:var(--ink); opacity:.9; text-shadow:2px 2px 0 var(--paper), -1px -1px 0 var(--paper);}
/* ⭐⭐⭐ v0.771 — THE CART, TURNED OVER. An album slot for a card you have not collected shows the
   card BACK instead of a "?" — same cartridge, same size, mirrored: the notch is on the top LEFT.
   ⚠ The notch is THREE rules (clip-path + the two pseudo-elements that paint its elbow at frame
   weight), so all three are mirrored here. Mirroring only the clip-path leaves the elbow floating
   on the right-hand side over the black.
   ⚠ No `transform:scaleX(-1)` — that would mirror the ink drop and the dots with it. The shell is
   flipped; the artwork on the back is not.
   ⚠ The frame is the card's own 3px, so the back's inner 2px border comes off, and the grip ridges
   are switched off — they are printed on the FRONT of a cart. */
.card.cardrev{
  --fc:var(--ink); background:var(--cream); background-image:none;   /* v0.787 — the card's own colour, not black */
  clip-path:polygon(12px 0, 100% 0, 100% 100%, 0 100%, 0 10px, 12px 10px);
}
.card.cardrev::before{right:auto; left:calc(12px - var(--bw));}
.card.cardrev::after{right:auto; left:calc(-1*var(--bw));}
.card.cardrev .cardback{border:none; padding:11% 9%;}
.card.cardrev .cb-drop{width:43.2%;}   /* an album tile is half the size of a pack reveal — the shape takes the extra room back (v0.777: halved with the rest; v0.787: +20%) */
/* ===== card FLIP reveal + INKVILLE card back ===== */
.cflip{perspective:900px; display:inline-block; position:relative;}
.cflip-in{position:relative; transform-style:preserve-3d; animation:cflipR .66s cubic-bezier(.34,1.28,.5,1) forwards;}
.cflip-face{backface-visibility:hidden; -webkit-backface-visibility:hidden;}
.cflip-front{position:relative;}
.cflip-back{position:absolute; inset:0; transform:rotateY(180deg);}
#hand .hcard{animation:handFloat 3.4s ease-in-out infinite;}   /* the hand HOVERS — drag me, it says */
#hand .hcard:active,#hand .hcard.dragging{animation:none;}
@keyframes handFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
/* a freshly-drawn card RISES IN from the energy bar below — invisible, then it fades up into the hand */
#hand .hcard.onfield{filter:grayscale(.65) brightness(.82);}   /* this piece is already on your side — greyed until it falls */
#hand .hcard.hcNew{animation:hcNewIn .44s ease-out both;}
@keyframes hcNewIn{0%{transform:translateY(82px); opacity:0}60%{opacity:1}100%{transform:translateY(0); opacity:1}}
@keyframes cflipR{0%{transform:rotateY(180deg)}100%{transform:rotateY(0)}}
/* ⭐ v0.393 — THE CARD BACK, SIMPLE: one white inklet, cartridge dots top and bottom.
   ⭐⭐ v0.787 (Odaroo) — IT IS NO LONGER BLACK. The back is the CARD'S OWN COLOUR — the same
   cream the front of every cart is printed on — flat, edge to edge, with no grip ridges and no
   pattern ("just the colour everywhere"). ⭐ v0.789 — the inklet in the middle is BLACK now, not
   white: on a cream back a white mark barely showed, and black pops. ⚠ Do NOT re-introduce `background-image` here: `.card` paints grip ridges and
   `.card.acard` paints two runs of them, so a back that inherits them stops being flat. */
.cardback{position:absolute; inset:0; background:var(--cream); background-image:none; border:2px solid var(--ink); border-radius:0; overflow:hidden;
  display:flex; flex-direction:column; align-items:center; justify-content:center; gap:7%; padding:9% 8%; box-sizing:border-box;}
/* ⚠ v0.776 — the silhouette is the INKLET now (91x81), not the old 18x24 drop, so the same
   HEIGHT on the card needs a wider box: 46% -> 62%. Sizing by width alone silently shrank it.
   ⭐ v0.777 (Odaroo) — then HALVED: 62% -> 31%. It is a little maker's mark on the back, not the
   subject of the card. ⚠ The 50% is of the WIDTH, and the box keeps its aspect (height:auto), so
   the shape ends up a QUARTER of the area — which is what "half the size everywhere" looks like.
   ⭐ v0.787 (Odaroo) — then 20% BIGGER: 31% -> 37.2%. Same centre, same shape, one step up. */
.cardback .cb-drop{width:37.2%; height:auto; display:block; flex:none;}
/* ⭐ v0.789 (Odaroo) — THREE dots top, THREE dots bottom, BLACK, tucked in close to the inklet so
   the back reads as one simple vertical column: dots · inklet · dots. The column is CENTRED
   (justify-content on .cardback) rather than pushed to the card's edges, and the row is narrow
   (40%) so the three dots sit under the shape instead of spanning the whole card. */
.cardback .cb-dots{display:flex; gap:10%; width:40%; flex:none; justify-content:space-between;}
.cardback .cb-dots b{flex:1; aspect-ratio:1/1; background:var(--ink); display:block;}
.cardback .cb-w{font-family:var(--fontD); font-size:11px; letter-spacing:1px; color:var(--cream);}
.cb-logo{font-family:var(--fontD); font-size:13px; line-height:1.35; letter-spacing:1px; text-align:center; color:var(--gold); text-shadow:1px 1px 0 #000; z-index:1;}
.cb-logo span{display:block; font-size:10px; color:var(--cream); letter-spacing:2px;}
.cb-star{width:22px; height:22px; z-index:1; opacity:.9;}
/* twin sashes: IN-DECK holds still (blue), UP! flashes (gold) — same cut, different colours */
.card .ndk{
  position:absolute; top:60%; left:-18%; right:-18%; z-index:6;   /* its own element — the notch contour ::before stays intact */
  background:#4a6fa5; color:var(--cream); text-align:center; font-size:12px;
  letter-spacing:3px; padding:2px 0; transform:rotate(-14deg);
  border:2px solid var(--ink); pointer-events:none;
}
.upchip{position:absolute; top:30%; left:-12%; right:-12%; z-index:4; text-align:center;
  transform:rotate(-14deg); font-size:13px; letter-spacing:3px; padding:2px 0; font-family:var(--fontD);
  background:var(--gold); color:var(--ink); border:2px solid var(--ink);
  box-shadow:0 0 14px #d9a232cc; animation:upFlash .8s steps(2) infinite;}
@keyframes upFlash{0%,100%{filter:brightness(1)}50%{filter:brightness(1.55)}}
/* (retired bouncing red arrow — the flashing sash does the talking) */
.upflag{display:none!important; position:absolute; z-index:9; width:22px; height:22px; border-radius:50%; background:var(--red); color:#fff;
  border:2px solid var(--ink); display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:bold;
  box-shadow:0 0 10px #c93a22cc, 0 2px 0 #1b1510; animation:upbounce .8s ease-in-out infinite; pointer-events:none;}
.card .upflag{top:2px; left:50%; margin-left:-11px;}
.gcard{position:relative;}
.gcard .upflag{top:6px; right:6px;}
/* WARDROBE visual grid */
.wardgrid{display:grid; grid-template-columns:repeat(2,1fr); gap:10px; margin-top:8px; max-height:min(58vh,430px); overflow-y:auto; padding:8px 2px;}   /* BIG box — see the whole closet */
.warditem{overflow:visible!important;}
.warditem .wardart{width:58px; height:64px; margin:-14px auto 0; transform:scale(1.35); transform-origin:bottom center; filter:drop-shadow(0 3px 5px #0006); position:relative; z-index:2;}   /* gear POPS out of the square, zoomed like featured artwork */
.warditem .wardart svg{width:100%; height:100%;}
.warditem{border:2px solid var(--ink); background:var(--paper2); padding:6px 4px 8px; text-align:center; display:flex; flex-direction:column; align-items:center; gap:3px;}
.warditem.worn{background:var(--gold); box-shadow:0 0 0 2px var(--gold2) inset;}
.wardart{width:52px; height:64px;}
.wardart svg{width:100%; height:100%; image-rendering:pixelated;}
.wardnm{font-family:var(--fontD); font-size:10px; letter-spacing:.5px; line-height:1.2;}
.wardstat{opacity:.85;}
/* THE FILE — tabbed, bigger box */
.file-av{width:88px; height:88px; margin:8px auto 6px; border-radius:50%;   /* the SAME lens disc as the HOME camera */
  background:radial-gradient(circle at 34% 28%, #56628a, #141824 72%); border:3px solid #d9d2c0;
  box-shadow:inset 0 0 8px #000c, 0 0 0 2px var(--ink);
  display:flex; align-items:center; justify-content:center; overflow:visible;}
.file-av svg{width:78%; height:78%; image-rendering:pixelated; filter:drop-shadow(0 0 8px #7ab6ff99);}
.modal{scrollbar-width:none;} .modal::-webkit-scrollbar{width:0; height:0; display:none;}   /* the big modal scrollbar stays out of sight */
@keyframes newsPop{0%{transform:scale(.06) rotate(-560deg); opacity:.15}100%{transform:scale(1) rotate(0); opacity:1}}
.btn.godshine{background:linear-gradient(120deg,#d9a232,#f4c542,#fff6cf,#f4c542,#d9a232)!important; background-size:300% 100%; animation:godShine 1.5s linear infinite;}
@keyframes godShine{from{background-position:0 0}to{background-position:300% 0}}
.fgear{position:relative; background:var(--paper2); border:2px solid var(--ink); border-radius:7px; padding:4px 2px 3px; cursor:pointer;}
.fgear.worn{background:var(--gold); box-shadow:0 0 0 2px var(--gold2) inset;}
.fgear-art{width:44px; height:48px; margin:0 auto;}
.fgear-art svg{width:100%; height:100%;}
.fgear-nm{font-family:var(--fontD); font-size:6px; line-height:1.25; min-height:15px;}
.fgear-tag{font-family:var(--fontD); font-size:7px; background:var(--ink); color:var(--cream); border-radius:4px; padding:1px 4px; display:inline-block;}
.fgear-tag.on{background:var(--teal);}
.fgear-tag.lock{background:var(--red);}
.inkgrid{display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin:12px 4px;}
.inkopt{height:66px; background:var(--paper2); border:3px solid var(--ink); border-radius:9px; cursor:pointer; padding:4px;}
.inkopt.sel{outline:3px solid var(--gold); box-shadow:0 0 10px #d9a23288;}
.inkopt svg{width:82%; height:92%;}
@keyframes upbounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
.famtab{position:relative;}
/* ⭐ v0.746 — the album's family tabs wear the same OUTSIDE badge as everything else: centred on the
   corner, half off the tab. It used to be a 12px pip inset 3px, i.e. three quarters of it inside the
   tab's own paper, which is the fault Odaroo reported on the album/mart squares. */
.tabdot{--ndot-sz:12px;
  position:absolute; top:calc(var(--ndot-sz) / -2); right:calc(var(--ndot-sz) / -2);
  width:var(--ndot-sz); height:var(--ndot-sz); border-radius:50%; background:var(--red); z-index:14;
  border:2px solid var(--ink); box-shadow:0 0 0 2px var(--cream), 2px 2px 0 rgba(27,21,16,.5);
  animation:pulse .9s infinite; pointer-events:none;}
/* stat icons */
.st{display:flex;align-items:center;gap:2px;}
.st svg{width:12px;height:12px;}
/* ---------------- grids/panels ---------------- */
.grid{
  display:grid; grid-template-columns:repeat(3,1fr); gap:12px;
  padding:14px; overflow-y:auto; flex:1; position:relative; z-index:5;
  align-content:start;
}
.grid::-webkit-scrollbar{width:5px} .grid::-webkit-scrollbar-thumb{background:var(--ink)}
.panel{
  background:var(--cream); border:3px solid var(--ink); box-shadow:5px 5px 0 var(--ink);
  padding:14px;
}
/* ---------------- modal ---------------- */
.modal-bg{
  position:absolute; inset:0; z-index:400; background:#1b1510cc;   /* GOLD STANDARD: the pause menu outranks every game screen's chrome — START always shows it */
  display:none; align-items:center; justify-content:center; padding:16px 20px calc(246px + var(--padny,0px));   /* the D-PAD ZONE is reserved — no dialog ever sits on the pad */
}
.modal-bg.on{display:flex;}
.modal{
  background:var(--paper); border:4px solid var(--ink); box-shadow:8px 8px 0 #000a;
  max-width:340px; width:100%; padding:18px; position:relative;
  animation:popIn .4s var(--pop); max-height:100%; overflow-y:auto;   /* fills the safe area above the pad, scrolls inside if taller */
}
@media (min-width:640px){ .modal{max-width:520px;} }   /* the unfolded Fold gets a proper window, not a phone sliver */
.modal:has(.lobby){max-width:380px;}   /* the LOBBY gets a wider window so VS never crowds the names */
@media (min-width:640px){ .modal:has(.lobby){max-width:580px;} }
/* ⭐ v0.420 — THE "?" DIALOGS SIT AT THE TOP. Every other dialog in the game is a decision and
   belongs in the middle of the glass; HOW TO PLAY is something you read while looking at the board
   it describes, so it goes up and out of the way. Scoped to .helptop on purpose — nothing else in
   the game moves. */
.modal-bg:has(.helptop){align-items:flex-start;}
@keyframes popIn{from{transform:scale(.6) rotate(-4deg); opacity:0} to{transform:none; opacity:1}}
/* ---------------- toast ---------------- */
/* ⭐⭐⭐ v0.567 — EVERY NOTIFICATION IS A STRIP OF FILM. (Odaroo, 2026-08-04: "the film reel look
   should be used for ALL status messages and banners… that SAME quality of layout.")
   The toast is the one box every message in the game passes through — a purchase, an unlock, a
   cheat call, a warning — so converting it converts them all in one edit rather than hunting
   banners screen by screen. It is now the Crown reel's own recipe: black film, cream sprocket
   holes along the top and bottom edges, the word in the middle.
   ⚠ THE PAPER BORDER IS GONE. A 2px cream rule all the way round read as a frame drawn ON the
   film and fought the holes for the same edge; a real strip has perforations, not a border.
   ⚠ NO `background:` SHORTHAND — the sprockets are a background-IMAGE from .inkfilm-sign above.
   ⚠ THE VERTICAL PADDING IS WHAT KEEPS THE WORDS OFF THE HOLES. 9px would put a 17px line box
     straight through the top row (holes sit 3.5→9.5px in from each edge at this scale), so it is
     13px and the strip grew 8px, which is the strip being a strip. */
/* ⭐⭐⭐ v0.711 — A NOTIFICATION HANGS UNDER THE HUD BAR, CENTRED, AND IS BIG ENOUGH TO READ.
   (Odaroo, 2026-08-06: "shows below the game HUD on the center screen… larger font below the main
   reel from all the screens… clear, concise, visually calming.")
   ⚠⚠ THIS REVERSES v0.699's "just above the bar if there is room". See the long note on
   inkNoteTop() in js/ui.js — there is ONE position now and it is below.
   ⚠ THE `top` IS WRITTEN INLINE by inkNoteTop() on every call — a CSS rule cannot follow a bar
   whose height changes with the fold and with the match, so do not hard-code a number here. The
   56px is only the fallback for a screen with no bar to measure.
   ⚠ THE FONT IS clamp()ed, NOT FIXED. 17px was the same size on both folds; the strip is the one
   thing in the game you are meant to stop and read, so it scales up with the screen and is floored
   at a size that survives a folded phone. */
#toast{
  /* ⚠⚠ v0.711 — `fixed`, NOT `absolute`, AND THAT IS LOAD-BEARING. inkNoteTop() writes a `top`
     computed from getBoundingClientRect(), i.e. in VIEWPORT coordinates; while this was `absolute`
     the strip resolved against the body box instead and landed a constant ~10px higher than it was
     told to, which put it back on the HUD bar it had just been measured clear of. The badge card
     (.achpop) has always been `fixed` for the same reason. */
  /* ⭐⭐ v0.715 — THE STRIP RUNS THE WHOLE WIDTH OF THE SCREEN (Odaroo, 2026-08-06). It is a reel of
     film crossing the screen edge to edge, so a long message never has to wrap into a narrow box
     and can never be cut off. ⚠ It is NOT centred by translateX any more — a full-bleed box is
     already centred, and leaving the -50% in would have pushed it half a screen left. Every
     keyframe below had to drop it too.
     ⚠ The font is the MIDDLE GROUND between the old 12px whisper and v0.713's 26px shout: big
     enough to read as an important notification, small enough not to fill the screen. */
  position:fixed; top:56px; bottom:auto; left:0; right:0; width:100%; transform:translateY(0) scale(0);
  /* ⭐ v0.743 — 80% OF v0.716's SIZE (Odaroo, 2026-08-07: "I find it too big, and sometimes it
     could be a little bit too much"). clamp(16,4.4vw,21) → clamp(13,3.5vw,17). It is still the
     middle ground the v0.716 note describes, just a step quieter — and the extra room is what
     keeps a name + "HAS ACCEPTED YOUR CHALLENGE!" on two full-size lines on the folded phone
     instead of being shrunk into them. */
  background-color:#0d0a08; color:var(--cream); font-size:clamp(13px,3.5vw,17px); z-index:9995;   /* above EVERY overlay incl. the style dossier — a purchase toast always lands on top */
  /* ⚠ v0.734 — 13px → 10px. Odaroo: "no thick box". 10px still clears the 7px sprocket row
     at each edge, which is the only thing the vertical padding is actually protecting. */
  padding:10px 16px; border:0; white-space:normal;
  max-width:none; box-sizing:border-box; text-align:center; line-height:1.4;
  font-family:var(--fontD); letter-spacing:1px; pointer-events:none;
  /* ⚠ v0.734 — pointer-events stays OFF by default and is switched on ONLY for a strip that has
     somewhere to take you (`.tapme`, set by toast()'s third argument). A notification that is
     always tappable is a notification that eats a tap meant for the playfield underneath it. */
  box-shadow:0 3px 0 rgba(13,10,8,.55);
}
/* the strip's own gauge — a status sign is thicker than a username, so its holes are drawn bigger,
   exactly the way the RECONNECTING banner's are. The centred/mirrored placement is inherited. */
/* ⚠⚠⚠ v0.734 — THE PRIVATE GAUGE THAT USED TO LIVE HERE IS DELETED. It set 11px/4px/4px/3.5px in
   cream, which made the notification strip the only piece of film in the game with its own period,
   its own hole size AND its own colour — three ways to be "nearly" the house style. The strip now
   takes the shared big gauge (--film2-*, css/base.css :root) like the HUD reel and the connecting
   banner do. Do not reintroduce a per-strip override: "a status sign is thicker so its holes are
   drawn bigger" is exactly the reasoning that produced four different reels. */
#toast{display:flex; align-items:center; gap:8px; justify-content:center;}
/* v0.317 — the two-line cheat call: each line locked to ONE line, font scales with the screen (folding
   mid-play re-computes it instantly since it rides vw) */
#toast .cheat2l{display:flex; flex-direction:column; gap:3px; min-width:0; align-items:center;}
/* ⚠ v0.745 — this legacy two-liner had its OWN clamp (12/3.9vw/18), a third size of notification
   type. noteLines() turns every .cheat2l message into the .nt-2l sign nowadays so nothing should
   reach it, but a size written down is a size that can come back — it takes the strip's own. */
#toast .cheat2l i{font-style:normal; display:block; white-space:nowrap; font-size:1em; color:var(--cream); line-height:1.35;}
/* ⭐⭐⭐ v0.733 — THE TWO-LINE SIGN. (Odaroo: "always two lines", "the maximum size length-wise".)
   ⚠ THE SECOND LINE KEEPS ITS ROW EVEN WHEN IT IS EMPTY (`min-height:1.25em`) — that is what makes
   every notification in the game the same size of sign, so the strip never grows or shrinks between
   one message and the next.
   ⚠ Both lines take the WHOLE width and ellipsise rather than wrap: the strip is full-bleed already
   (see the #toast block above), so a line that needed to wrap would be a line that is too long to
   read at a glance anyway. */
#toast .nt-2l{display:flex; flex-direction:column; align-items:center; justify-content:center;
  width:100%; min-width:0; gap:1px; text-align:center;}
/* ⭐⭐⭐ v0.746 — NOTHING IS EVER CUT OFF WITH THREE DOTS. THE SIGN GETS TALLER INSTEAD.
   (Odaroo, 2026-08-07: "never use ellipsis. If it doesn't fit on line 2, let it flow to a THIRD
   line at the same reel size — the reel just gets taller.")
   This REVERSES the v0.733 rule directly above: `nowrap` + `text-overflow:ellipsis` was chosen
   because the strip is full-bleed and a line that had to wrap was assumed to be a line too long to
   read at a glance. It isn't — a third row of the same film is perfectly readable and losing the
   end of a sentence never is. ⚠ `overflow-wrap:anywhere` matters: a single unbroken token (a
   pasted room code, a rival's name with no spaces) has no break opportunity and would otherwise
   run straight out of the box, which is the one way wrapping can still lose a word. */
#toast .nt-2l i{font-style:normal; display:block; width:100%; min-width:0; text-align:center;
  line-height:1.25; white-space:normal; overflow:visible; overflow-wrap:anywhere;}
/* ⭐⭐⭐ v0.745 — ONE FONT, ONE SIZE, ONE COLOUR ON BOTH LINES. (Odaroo, 2026-08-07: "there are two
   different font sizes for the toast pop-ups… same font, same colour.")
   The detail line used to be .9em in #e8dcc4 — a second size and a second cream on the same sign,
   which is exactly what he photographed. Both lines are now 1em of the strip's own clamp() and
   var(--cream), and the shrink-to-fit in toast() steps them together as it always did.
   ⚠ DO NOT re-introduce a size or colour of its own on either line. The headline says WHAT happened
   and the detail says which one; the hierarchy is the wording, not the type. */
/* ⚠⚠ v0.746 — THE HEADLINE'S SIZE IS THE STRIP'S SIZE AND NOTHING MAY TAKE IT DOWN (Odaroo:
   "line 1 never shrinks; only the detail lines get smaller if there's too much info"). toast()
   now sizes the two lines SEPARATELY for exactly this reason: the wrapper carries the headline's
   size, and only `.nt-sub` is ever given an inline size of its own, and only when two rows of
   detail will not fit at full size. That is the one sanctioned exception to the v0.745 rule above
   — the default, and what the player sees on virtually every message, is still one size. */
#toast .nt-2l .nt-hd{font-size:1em; color:var(--cream);}
#toast .nt-2l .nt-sub{font-size:1em; color:var(--cream); min-height:1.25em;}
#toast.tapme{pointer-events:auto; cursor:pointer;}
.toast-av{width:32px; height:36px; flex:none; display:flex; align-items:flex-end; overflow:hidden; margin:-3px -1px -6px -9px;}
.toast-av svg{width:100%; image-rendering:pixelated;}
/* ⭐⭐ v0.711 — CALM, NOT JARRING. The old strip banged in from scale(0) with a 1.08 overshoot and
   a ±5deg wobble, then snapped shut the same way — on a message you are being asked to READ, the
   motion was the loudest thing about it. It now settles down from a few pixels above the bar,
   holds perfectly still for the whole middle of its life, and fades. No rotation, no overshoot.
   ⚠ THE DURATION IS `--noteMs`, WRITTEN INLINE BY toast(), because how long it stays depends on
   how much there is to read (noteHold in js/ui.js). A fixed number here and a computed timer there
   would drift apart. The percentages are proportions, so they stretch with it.
   ⚠ v0.715 — the translateX(-50%) that every keyframe used to restate is GONE, because the strip is
   full-bleed now (left:0;right:0) and centres itself. Restating it here would slide the whole reel
   half a screen off the left edge. */
/* ⭐⭐⭐ v0.719 — THE MESSAGE STRIP IS THE **SAME REEL AS THE CONNECTING BANNER**, AND IT ROLLS.
   (Odaroo, 2026-08-06: "the toast box notifications should have the same layout as CONNECTING and
   CONNECTED — not static dots, but the same dots slowly moving from left to right, the same size
   if possible, and fit all the text inside.")
   So the recipe below is lifted from #crLineBadge.cr-linebadge (css/crown.css, the block at the
   very bottom of that file) rather than re-invented: the SAME doubled period, the SAME 7px hole
   rows pinned to the top and bottom edges, and the SAME crlFilmRoll shift of exactly one period.
   ⚠⚠ IT MUST COME AFTER the `#toast.inkfilm-sign` gauge above and after the `#toast` block, or the
   static sprockets from .inkfilm-holes win on source order and the film stands still. The reason it
   is written at id-level here is the same one crown.css gives for its own last-in-file block.
   ⚠⚠ `animation` IS A SHORTHAND AND `toastIn` ALREADY OWNS THE SLOT — the roll has to be listed in
   the SAME declaration or naming either one alone silently deletes the other. (The v0.664 trap,
   restated because this is the second place in the game it bites.)
   ⚠ THE ROLL IS X-ONLY. The Y half of background-position is what pins the two rows to the top and
   bottom edges; a keyframe writing the shorthand flings them both into the middle of the words.
   ⚠ 1.6s is the CONNECTED speed, not the 0.34s "line caught" one — a notification is something you
   read, so the film ambles rather than races. */
#toast.inkfilm-sign{
  /* ⚠ THE GAUGE IS TAKEN BACK TO THE ROOT VALUES ON PURPOSE. The v0.567 block above overrides
     --film-per to 11px (a 22px period once doubled), which is half as many holes as the connecting
     banner has — and "the same size, if possible" was the ask. These are :root's numbers, restated
     so the two strips are provably the same film. */
  /* ⭐⭐⭐ v0.734 — THE SHARED BIG GAUGE, NOT A COPY OF IT. This block used to restate
     `--film-w:4px` and a cream hole colour, so the notification's perforations came out 8px wide
     in cream against the 6px white ones on the bar directly above it. Every number now comes from
     --film2-* on :root. */
  background-image:
    linear-gradient(90deg,
      transparent 0 calc((var(--film2-per) - var(--film2-w))/2),
      var(--film-hole) calc((var(--film2-per) - var(--film2-w))/2) calc((var(--film2-per) + var(--film2-w))/2),
      transparent calc((var(--film2-per) + var(--film2-w))/2) var(--film2-per)),
    linear-gradient(90deg,
      transparent 0 calc((var(--film2-per) - var(--film2-w))/2),
      var(--film-hole) calc((var(--film2-per) - var(--film2-w))/2) calc((var(--film2-per) + var(--film2-w))/2),
      transparent calc((var(--film2-per) + var(--film2-w))/2) var(--film2-per));
  background-size:var(--film2-per) var(--film2-h), var(--film2-per) var(--film2-h);
  background-position:0 0, 0 100%;
  background-repeat:repeat-x, repeat-x;
  will-change:background-position;
}
#toast.on{animation:toastIn var(--noteMs,2600ms) ease forwards, crlFilmRoll 1.6s linear infinite;}
@keyframes toastIn{
  0%{transform:translateY(-10px) scale(1); opacity:0}
  9%{transform:translateY(0) scale(1); opacity:1}
  88%{transform:translateY(0) scale(1); opacity:1}
  100%{transform:translateY(-6px) scale(1); opacity:0}
}
/* ---------------- energy & battle ---------------- */
#scr-battle{background:var(--paper); padding-bottom:242px;}   /* v0.836 — tracks the deck: it was 224 against a 222px deck, and the deck is 240 everywhere now (see the UNIVERSAL D-PAD note in css/home.css). Keep this 2px above the pad height. */   /* the FIELD ends ABOVE the solid control deck — nothing plays behind the D-pad ever again */
#stage{
  position:relative; flex:1; min-height:0; overflow:hidden;
}
#stage .sunburst{opacity:.7}
#stage.hallway .slayer svg{animation:hallScroll 1.1s linear 2;}
#stage.hallway #ground svg{animation:hallScrollG .65s linear 4;}
@keyframes hallScroll{from{transform:translateX(0)}to{transform:translateX(-180px)}}
@keyframes hallScrollG{from{transform:translateX(0)}to{transform:translateX(-100px)}}
#stage.hallway .unit,#stage.hallway .leader{animation:marchBob .38s steps(2) infinite;}
.leader.fall{animation:towerFall 1s ease-in forwards!important;}
@keyframes towerFall{30%{transform:rotate(9deg)}100%{transform:rotate(76deg) translateY(46px); opacity:0}}
#hzLogo{position:absolute; top:8px; left:0; right:0; text-align:center; z-index:7; pointer-events:none; overflow:visible;
  font-family:var(--fontD); font-size:27px; line-height:1.2; padding:4px 0; letter-spacing:1px; color:#e23b2a;
  text-shadow:0 0 10px #e23b2aaa, 2px 2px 0 #1b1510;
  transform:rotate(-1deg); animation:hzLogoBob 2.4s ease-in-out infinite;}   /* TEXT-only marquee — BIG, never clipped */
@keyframes hzLogoBob{50%{transform:rotate(-1deg) translateY(-2px) scale(1.03)}}
/* right info panel — TIME / HI-SCORE / SCORE / SPEED LV, Panel-de-Pon style */
#hzInfo{position:absolute; left:1%; top:22%; width:82px; z-index:6; text-align:left; background:#1b1510dd; border:2px solid var(--gold); border-radius:6px; padding:5px 6px; box-shadow:2px 2px 0 #0007;}   /* stats ride on the LEFT under the inklet */
#hzInfo .hzi-l{font-family:var(--fontD); font-size:7px; color:#ff8ab0; letter-spacing:1px; margin-top:4px;}
#hzInfo .hzi-l:first-child{margin-top:0;}
#hzInfo .hzi-v{font-family:var(--fontD); font-size:11px; color:#7fe6ff; text-shadow:1px 1px 0 #000;}
#hzInfo .hzi-boss{margin-top:6px; text-align:center;}
#hzInfo .hzi-boss svg{width:52px; height:56px;}
#hzInfo .hzi-bnm{font-family:var(--fontD); font-size:7px; color:var(--gold); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
/* the animated dotted FINISH LINE inside the grid — clear everything ABOVE it */
#hzFinish{position:absolute; left:0; right:0; height:0; z-index:6; pointer-events:none;
  border-top:3px dashed #ffffff; filter:drop-shadow(1px 1px 0 #1b1510); animation:hzFinMarch .5s linear infinite, hzWinPulse 1.2s ease-in-out infinite;}   /* the SAME dashed white as the cursor */
@keyframes hzFinMarch{from{border-image:none; transform:translateX(0)}50%{transform:translateX(3px)}to{transform:translateX(0)}}
#hzFinish .hzf-flag{position:absolute; right:2px; top:-26px; font-family:var(--fontD); font-size:14px; color:#ffffff; text-shadow:2px 2px 0 #1b1510, 0 0 10px #ffffff88; letter-spacing:1px;}   /* BIG and white — a target, not a footnote */
#hzFreezeBn{position:absolute; top:8px; left:50%; transform:translateX(-50%); z-index:31; pointer-events:none;
  font-family:var(--fontD); font-size:22px; letter-spacing:3px; color:#bfefff;
  text-shadow:0 0 14px #2fe0ff, 2px 2px 0 #0b2530; animation:hzFrzBn .6s ease-in-out infinite alternate;}
@keyframes hzFrzBn{from{opacity:.75; transform:translateX(-50%) scale(1)}to{opacity:1; transform:translateX(-50%) scale(1.08)}}
#horrorWrap.frozen #hzPanels .hpan{filter:saturate(.35) brightness(1.28) hue-rotate(155deg);}   /* every block ICES over */
/* stage-clear O sweep — same fill, but victory TEAL rings */
.hpan.clearedO{background:#1f4f4a!important; border-color:#123531!important;}
.hpan.clearedO svg{opacity:.14;}
.hpan.clearedO::after{content:''; position:absolute; inset:22%; border:3px solid #4fe0c8; border-radius:50%;}
/* the '80s back room — treasures drifting, per-stage arrangement */
.hz80{position:absolute; z-index:1; pointer-events:none; opacity:.30; image-rendering:pixelated;
  animation:h80drift var(--dd,8s) ease-in-out infinite alternate;}
.hz80 svg{width:100%; height:auto; display:block;}
@keyframes h80drift{from{transform:translateY(0) rotate(var(--rr,0deg))}to{transform:translateY(-14px) rotate(calc(var(--rr,0deg)*-1))}}
/* the LEFT wing: one huge inklet + the heart bank (12 max, 6 per row); bonus hearts glitch */
#hzChar .hzCharArt.hzBig svg{width:74px; height:80px;}
#hzLives{display:flex; flex-wrap:wrap; gap:2px; max-width:84px; justify-content:center; margin-top:4px;}
.pxheart.glhe{animation:glheA 1.3s steps(1) infinite;}
@keyframes glheA{0%,78%{filter:none; transform:none}82%{filter:hue-rotate(140deg) saturate(2); transform:translate(1px,-1px)}90%{filter:invert(.2); transform:translate(-1px,0)}96%,100%{filter:none; transform:none}}
/* the zombie MALL — motionless silhouettes with X eyes, scattered everywhere (cheap on the GPU) */
.hzsil{position:absolute; pointer-events:none; filter:grayscale(1) brightness(.35); opacity:.85;}
.hzsil svg{width:100%; height:100%; animation:none!important;}
.hzsil .hzx{position:absolute; left:0; right:0; top:16%; text-align:center; font-family:var(--fontD); font-size:10px; color:#e23b2a; letter-spacing:3px; text-shadow:1px 1px 0 #000;}
.hzmallfloor{position:absolute; left:0; right:0; height:2px; background:#ffffff22;}
#hzWinLine{position:absolute; left:5%; right:5%; bottom:10px; z-index:7; height:0; pointer-events:none;
  opacity:0; transition:opacity .35s;
  background-image:repeating-linear-gradient(90deg,#2fe0a0 0 10px,transparent 10px 20px);
  background-size:20px 4px; background-repeat:repeat-x; background-position:0 0;
  border-top:4px solid transparent; box-shadow:0 0 12px #2fe0a0;}
#hzWinLine.on{opacity:1; animation:hzWinMarch .6s linear infinite, hzWinPulse 1s ease-in-out infinite;}
@keyframes hzWinMarch{from{background-position:0 0}to{background-position:20px 0}}
@keyframes hzWinPulse{0%,100%{box-shadow:0 0 8px #2fe0a0}50%{box-shadow:0 0 18px #2fe0a0,0 0 4px #fff}}
.gdrop{position:fixed; bottom:-64px; z-index:130; width:46px; height:46px; cursor:pointer;}
.gdrA{animation:gflA 5.6s linear forwards;}
.gdrB{animation:gflB 6.2s cubic-bezier(.5,0,.5,1) forwards;}
.gdrC{animation:gflC 5s steps(16) forwards;}
@keyframes gflA{0%{transform:translate(0,0) rotate(0)}22%{transform:translate(34px,-22vh) rotate(24deg)}46%{transform:translate(-30px,-46vh) rotate(-20deg)}70%{transform:translate(28px,-70vh) rotate(16deg)}100%{transform:translate(-8px,-99vh) rotate(-8deg)}}
@keyframes gflB{0%{transform:translate(0,0)}18%{transform:translate(-42px,-14vh)}36%{transform:translate(44px,-34vh)}54%{transform:translate(-34px,-54vh)}74%{transform:translate(40px,-76vh)}100%{transform:translate(0,-101vh)}}
@keyframes gflC{0%{transform:translate(0,0) scale(1)}20%{transform:translate(52px,-26vh) scale(1.16)}40%{transform:translate(-48px,-40vh) scale(.82)}60%{transform:translate(40px,-62vh) scale(1.1)}80%{transform:translate(-30px,-82vh) scale(.9)}100%{transform:translate(14px,-100vh) scale(1)}}
.gtok{width:46px; height:46px; filter:drop-shadow(2px 2px 0 #1b1510);}
.gtok svg{width:100%; height:100%; display:block;}
.gdcard{width:50px; height:66px;}
.gdcard .gball{width:50px; height:66px; border:3px solid #1b1510; background:#fdf6e3; box-shadow:0 0 16px 3px currentColor; position:relative; overflow:hidden; animation:gcGlitch .48s steps(2) infinite;}
.gdcard.r3{color:#8e3ad6;} .gdcard.r4{color:#d9a232;}
.gdcard .gball::before{content:''; position:absolute; inset:0; z-index:2; pointer-events:none; background:linear-gradient(115deg,transparent 40%,#ffffffdd 50%,transparent 60%); background-size:260% 100%; animation:gcSheen 1.2s linear infinite;}
/* v0.734 — the sparkle glyph is gone with every other star in the game (Odaroo, 2026-08-07). The
   mark stays: it is the shine on the golden box, now drawn as the box's own gold rather than as a
   character. */
.gdcard .gball::after{content:''; position:absolute; top:1px; right:3px; font-size:10px; color:#fff; z-index:3; text-shadow:0 0 5px currentColor; animation:gcTwinkle .7s steps(2) infinite;}
@keyframes gcSheen{to{background-position:-170% 0}}
@keyframes gcTwinkle{50%{opacity:.15; transform:scale(1.5)}}
@keyframes gcGlitch{0%,100%{transform:translate(0,0)}25%{transform:translate(-1.5px,1px)}50%{transform:translate(1.5px,-1px)}75%{transform:translate(-1px,0)}}
.gdrop.gout{animation:gcOut .48s steps(3) forwards;}
@keyframes gcOut{0%{opacity:1}30%{opacity:1; transform:translateX(7px) skewX(14deg); filter:hue-rotate(90deg) contrast(1.8)}60%{opacity:.6; transform:translateX(-9px) skewX(-16deg); filter:hue-rotate(220deg)}100%{opacity:0; transform:translateX(5px) scale(.65); filter:contrast(3)}}
@keyframes gfloat{from{transform:translateY(0)}to{transform:translateY(-96vh)}}
@keyframes gwob{50%{transform:rotate(5deg) scale(1.05)}}
.gdrop.pop{animation:gpop .34s ease-out forwards;}
@keyframes gpop{to{transform:scale(2); opacity:0}}
.gdrop.fade{animation:gfade .4s forwards;}
@keyframes gfade{to{opacity:0; transform:scale(.5)}}
.ginklet{width:46px; height:58px;}
.ginklet .ginkart{width:46px; height:58px; filter:drop-shadow(2px 2px 0 #1b1510) drop-shadow(0 0 8px #4a6fa5aa); animation:inkGl .1s steps(2) infinite;}
.ginklet .ginkart svg{width:100%; height:100%;}
.hpan.clr{animation:panClr .62s steps(4) forwards; z-index:2;}
#horrorGrid.slowfall .hpan{transition:top 1.05s cubic-bezier(.2,.8,.3,1);}   /* falls take their time — line up the CHAIN */
@keyframes panClr{50%{filter:brightness(2); transform:scale(1.12)}100%{filter:brightness(3); transform:scale(0); opacity:0}}
.hzmult{position:absolute; z-index:8; font-family:var(--fontD); font-size:30px; color:var(--red);
  text-shadow:3px 0 0 #4a6fa5,-3px 0 0 #d9a232,2px 2px 0 #1b1510; animation:multPop .9s ease-out forwards; pointer-events:none;}
@keyframes multPop{from{transform:scale(.4) rotate(-8deg); opacity:0}30%{transform:scale(1.4) rotate(4deg); opacity:1}to{transform:scale(1) translateY(-30px); opacity:0}}
.rngesus{position:absolute; left:50%; top:38%; transform:translate(-50%,-50%); z-index:9; font-family:var(--fontD);
  font-size:17px; color:#fdf6e3; background:#1b1510e0; padding:10px 16px; letter-spacing:4px;
  animation:rngFlicker 1.3s steps(2) forwards; pointer-events:none; border:2px solid var(--red);}
@keyframes rngFlicker{0%{opacity:0}20%{opacity:1}45%{opacity:.2}60%{opacity:1}85%{opacity:.4}100%{opacity:0}}
#scr-horror.hwp0{background:repeating-linear-gradient(45deg,#efe2c5 0 26px,#e6d5b0 26px 52px);}
#scr-horror.hwp1{background:repeating-radial-gradient(circle at 30% 20%,#e8d9ba 0 18px,#efe2c5 18px 36px);}
#scr-horror.hwp2{background:linear-gradient(#d8c8e8,#efe2c5);}
#scr-horror.hwp3{background:repeating-linear-gradient(90deg,#cfe3d8 0 30px,#dfeee5 30px 60px);}
#scr-horror.hwp0,#scr-horror.hwp1,#scr-horror.hwp2,#scr-horror.hwp3{animation:wallDrift 26s linear infinite alternate;}
/* ============================================================
   ⭐⭐ v0.442 — THE PRE-GAME DOOR, AND THE ONLINE BUTTON IN BABY BLUE.
   ------------------------------------------------------------
   The doorstep is three buttons and the game's name. The inkling-versus-question-mark stage that
   used to sit above them is gone: it promised an opponent before there was one, ate most of the
   box, and on the folded phone it pushed the three buttons — the entire point of the screen —
   down toward the controls.
   ONLINE is baby blue on Odaroo's word: the navy read as another "back" button next to the black
   one, and online is the friendly invitation on this screen, not the sober option.
   ============================================================ */
.inkdoor{display:flex; flex-direction:column; align-items:center; gap:2px;}
.inkdoor-h{font-size:15px; letter-spacing:1px; line-height:1.2; text-align:center;}
.inkdoor-jp{font-family:var(--fontJ,var(--fontD)); font-size:10px; opacity:.55; margin-bottom:12px;}
.inkdoor-acts{display:flex; flex-direction:column; align-items:center; gap:8px; width:100%;}
.inkdoor-acts .btn.opt{min-width:190px; max-width:100%;}

/* baby blue — light enough to read as an invitation, dark enough for cream text to clear contrast */
.btn.opt.sky{background:#7fc4f0; color:#10283a; border-color:#10283a;}
.btn.opt.sky span{color:#10283a;}
.btn.opt.sky:active{background:#63b3e6;}
/* a game whose online half is not built yet says so instead of pretending */
.btn.opt.sky.soon{background:#bcd9ea; color:#3b4a55; opacity:.85;}
.btn.opt.sky.soon span{color:#3b4a55;}

/* ============================================================
   ⭐⭐ v0.450 — INSTALLED, THE SYSTEM BARS ARE REAL AND THEY GET THEIR ROOM.
   In a browser tab the address bar already keeps the page off the status bar, and --sab is capped at
   16px on purpose so a phone reporting a large inset cannot shove the controls halfway up the glass.
   An INSTALLED app has no address bar and owns the whole panel: Samsung's own navigation bar sits
   under the game and the status bar over it, and the cap is then exactly wrong — it lets the D-pad
   run under the back/home/recents row. So in standalone the real insets are honoured in full.
   ⚠ This is the only place the two contexts are allowed to differ. Everything else measures.
   ============================================================ */
@media (display-mode: standalone), (display-mode: fullscreen){
  :root{
    /* ⚠ v0.855 — --sab-src, NOT --sab. This is the DEVICE's reading; the latch in js/core.js turns
       it into --sab. Writing --sab here would be overridden by the inline latch anyway, and the
       probe would then be reading our own answer back — see the note at the top of this file. */
    --sab-src:env(safe-area-inset-bottom,0px);
    --sat-src:env(safe-area-inset-top,0px);
  }
  /* ⚠⚠ AND THIS IS THE WORST CASE, WHICH IS WHY IT IS FILMED HERE AND NOWHERE ELSE. In a browser
     tab the cap above holds the swing to 16px; in fullscreen/standalone the inset is honoured in
     full, so Chrome re-showing its navigation bar around a touch moves it by the bar's whole
     height — ~48px — and #gpad's height, the D-pad, A/B, SELECT/START and --padclear all move
     with it. Samsung Internet keeps its furniture in a fixed place and never does this. */
  /* nothing draws under the status bar: the app box starts below it */
  /* ⭐⭐⭐ v0.855 — var(--sat), NOT the raw env(). This one line sizes THE WHOLE GAME BOX, so a
     status bar that comes and goes moved every pixel of the game down and back up again — the top
     half of the same fault the navigation bar causes at the bottom, and the more visible of the
     two. --sat is the LATCHED inset (inkSabWatch, js/core.js); --sat-src is what the device says.
     ⚠ It is `var(--sat)` with no fallback to env() on purpose: an env() fallback would quietly
     restore the moving value on any build where the latch failed to run, and a fault that only
     appears when the fix is broken is a fault nobody will ever find. --sat is defined above. */
  #app{padding-top:var(--sat,0px); box-sizing:border-box;}
}
/* ⭐⭐ v0.570 — IN TRUE FULLSCREEN THE ART RUNS BEHIND THE CAMERA CUTOUT.
   There is no status bar to avoid in display-mode:fullscreen — the only thing left up there is the
   hole in the glass. Padding #app down for it leaves a dead letterbox band above the game, which is
   exactly the "browser UI still showing" look Odaroo is trying to be rid of. So the box goes
   full-bleed and the inset is handed to --sat instead.
   ⚠ THIS IS ONLY SAFE BECAUSE EVERY HUD ELEMENT ALREADY OFFSETS ITSELF by env(safe-area-inset-top)
   — the wordmarks, stage tags, .vsnm name strips, #crTurn, the rink HUD, the tutorial box. Art may
   pass under the cutout; text and controls may not. Anything new that lives in the top ~40px must
   add the same offset, or it will sit under the hole on a Fold.
   ⚠ standalone is untouched above: there the status bar is REAL and still gets its room. */
@media (display-mode: fullscreen){
  #app{padding-top:0;}
}
/* ============================================================
   ⭐⭐⭐ v0.578 — THE NAME STRIP STOPS DODGING A STATUS BAR THAT IS NOT THERE. (Odaroo, 2026-08-04:
   "the username / film reel has been pushed down — it needs to be back in the top-left corner.")

   He read it exactly right. Since v0.570 the game runs in TRUE fullscreen, so there is no status bar
   above it — but `env(safe-area-inset-top)` does not go to zero when the status bar goes away. It
   reports the CAMERA CUTOUT, which on the Fold is around 30-40px, and every HUD element in the game
   adds that number to its own top offset (deliberately — see the note above). The name strip was
   therefore sitting ~40px down a screen with nothing above it, and the portrait that vsNamePlace
   hangs underneath it went down with it and ran out of room.

   ⚠⚠ THE CUTOUT IS A HOLE IN THE MIDDLE OF THE TOP EDGE, NOT A BAR ACROSS IT. That is the whole
   reason this can be capped and the general rule cannot: the name strip is anchored in the CORNERS
   (one cell hard left, one hard right, and in solo just the left one), and the corners are glass.
   Something CENTRED at the top — a wordmark, #crTurn, a stage tag — genuinely can be swallowed by
   the punch-hole and must keep using the full inset. So this var is for corner-anchored HUD only.
   ⚠ STANDALONE IS UNTOUCHED. There the status bar is real, it is a full-width bar, and the strip
   must clear all of it — so --satnm falls back to the true inset outside true fullscreen.
   ============================================================ */
:root{ --satnm:env(safe-area-inset-top,0px); }
@media (display-mode: fullscreen){
  :root{ --satnm:min(env(safe-area-inset-top,0px), 4px); }
}
/* ⭐⭐⭐ v0.581 — AND THE SAME CAP FOR RUNTIME FULLSCREEN, WHICH IS THE CASE THAT ACTUALLY HAPPENS.
   The media query above only describes how the page was LAUNCHED — it matches an installed PWA
   asking for display:"fullscreen" and does NOT reliably match a browser tab that went fullscreen
   through `requestFullscreen()`, which is the path this game takes on the very first tap. So on a
   phone opening the site in the browser the cap never applied and the strip kept dodging a ~40px
   camera cutout with nothing above it. `.ink-fs` is written onto <html> from `inkFsClass()`
   (js/ui.js) off `fullscreenchange`, so this is the runtime truth to the media query's launch
   truth. BOTH ARE NEEDED — an installed fullscreen PWA may have no fullscreenElement at all.
   ⚠ 0px, NOT 4px. Odaroo has asked twice for the reel to sit FLUSH in the top-left corner, and the
   4px above was a hedge, not a measurement. The corners of the glass are clear — the cutout is a
   hole in the MIDDLE of the top edge (see the v0.578 note above), so a corner-anchored strip owes
   it nothing. Anything CENTRED up there still uses the full inset and must keep doing so. */
html.ink-fs{ --satnm:0px; }

/* ⚠⚠⚠ v0.567 — THE LEVEL TAB IS DELETED. The player level and XP are removed from the game, so
   .gink-lvt (the LV box under every inkling portrait) and all of its size/scoping rules are gone.
   ⚠ CARD levels are a different system and are untouched. Do not re-add a .gink-lvt. */

/* ============================================================
   ⭐⭐ v0.525 — THE QUICK STATS ROLL, not a screen.
   Odaroo: "tapping the enemy inkling shows their stats AND mine — don't make it a big screen, a
   small box that unrolls with wins and cups."  So it is a strip that grows out of the top edge of
   the screen under the two names, two columns, four numbers, gone on the next tap.
   ============================================================ */
.vsroll{position:absolute; left:50%; transform:translateX(-50%); z-index:60;
  width:calc(212px * var(--gs,1)); max-width:88vw;
  background:#12100ef2; border:calc(2px * var(--gs,1)) solid #f6f2e8;
  border-radius:calc(5px * var(--gs,1)); box-shadow:0 calc(4px * var(--gs,1)) 0 #0009;
  padding:calc(5px * var(--gs,1)) calc(6px * var(--gs,1));
  display:grid; grid-template-columns:1fr 1fr; gap:calc(3px * var(--gs,1)) calc(6px * var(--gs,1));
  font-family:'Press Start 2P', monospace; color:#fff8ea;
  transform-origin:50% 0; animation:vsRollOut .18s ease-out;}
@keyframes vsRollOut{from{transform:translateX(-50%) scaleY(.05); opacity:0}
                     to{transform:translateX(-50%) scaleY(1); opacity:1}}
.vsroll h5{margin:0 0 calc(2px * var(--gs,1)); font-size:calc(7px * var(--gs,1)); line-height:1.2;
  letter-spacing:.5px; color:#ffd6a0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.vsroll.foe h5{color:#ffd6cc;}
.vsroll u{display:flex; justify-content:space-between; gap:calc(4px * var(--gs,1));
  text-decoration:none; font-size:calc(6px * var(--gs,1)); line-height:1.5;}
.vsroll u s{text-decoration:none; opacity:.6;}
.vsroll u b{color:#f4c542;}
.vsroll .vscol{min-width:0;}

/* ============================================================================
   ⭐⭐⭐ v0.546 — THE IN-GAME KEYBOARD (#inkKb). The phone's own keyboard is never opened by a
   name field; this paper-and-ink QWERTY takes its place. It is a CHILD OF #app and absolutely
   positioned, so it rides --appvh like everything else and modalFit() can measure it.
   ⚠ z-index 520 puts it over the D-pad (z460) on purpose — while you are typing the pad is not
   what the thumb wants, and the pad's own buttons are routed into the keyboard anyway (gpad).
   ⚠ Keys are sized off the CONTAINER, never the viewport: 10 to a row at flex:1 means a folded
   375px screen and an unfolded 906px one get the same layout at different scales, and no keycap
   can ever be too small to hit. */
#inkKb{
  position:absolute; left:0; right:0; bottom:0; z-index:520;
  background:var(--paper); border-top:4px solid var(--ink);
  box-shadow:0 -6px 0 #1b151033;
  padding:6px 5px calc(7px + var(--sab,0px));
}
/* ⚠⚠ NO SLIDE-IN ANIMATION HERE. A transform keyframe on this panel leaves it parked at
   translateY(100%) — completely off the bottom of the screen — in any harness that does not
   composite (the browser pane never fires rAF, so nothing advances the animation). The keyboard
   would then measure as present and be invisible. It arrives instantly instead. */
#inkKb .kbTop{display:flex; align-items:center; justify-content:space-between; gap:8px;
  margin:1px 3px 6px; padding:5px 8px; background:var(--cream);
  border:3px solid var(--ink); box-shadow:2px 2px 0 var(--ink); min-width:0;}
#inkKb .kbEcho{font-family:var(--fontD); font-size:clamp(10px,3.4vw,15px); color:var(--ink);
  letter-spacing:1px; white-space:nowrap; overflow:hidden; text-overflow:clip; min-width:0;}
#inkKb .kbEcho.kbGhost{opacity:.35;}
#inkKb .kbEcho::after{content:'_'; animation:kbBlink 1s steps(1,end) infinite;}
@keyframes kbBlink{50%{opacity:0}}
#inkKb .kbCount{font-family:var(--fontD); font-size:8px; color:var(--ink); opacity:.55; flex:0 0 auto;}
#inkKb .kbRow{display:flex; gap:4px; justify-content:center; margin-bottom:4px;}
#inkKb .kbRow:last-child{margin-bottom:0;}
#inkKb .kbK{
  /* v0.616 — keycaps are ~25% TALLER than v0.546's 34/9.4vw/50 so the folded screen is thumb-sized.
     Five rows at this height plus the echo bar is ~295px of an 812px screen; modalFit reserves it. */
  flex:1 1 0; min-width:0; height:clamp(43px,11.8vw,62px);
  font-family:var(--fontD); font-size:clamp(12px,3.7vw,18px); line-height:1;
  color:var(--ink); background:var(--cream);
  border:3px solid var(--ink); border-radius:4px; box-shadow:0 3px 0 var(--ink);
  display:flex; align-items:center; justify-content:center;
  cursor:pointer; user-select:none; touch-action:manipulation; padding:0;
}
#inkKb .kbK.kbHit{transform:translateY(3px); box-shadow:0 0 0 var(--ink); background:var(--gold);}
#inkKb .kbFn{background:var(--paper2); font-size:clamp(15px,4.7vw,23px);}
#inkKb .kbShift.on{background:var(--gold); color:var(--ink);}
#inkKb .kbWide{flex:2 1 0; font-size:clamp(11px,3.2vw,16px); letter-spacing:1px;}
#inkKb .kbOk{background:#1f7a3a; color:#fff;}        /* GO = green, the house rule — a shade darker than the house green so WHITE clears 4.5:1 on it (crReadable caught 3.4:1) */
#inkKb .kbClr{background:var(--red,#c93a22); color:#fff;}
/* the unfolded Fold gets a proper keyboard, not a stretched phone one */
@media (min-width:640px){ #inkKb{padding:9px 8px calc(10px + var(--sab,0px));}
  #inkKb > .kbRow, #inkKb .kbTop{max-width:620px; margin-left:auto; margin-right:auto;}
  #inkKb .kbRow{gap:6px; margin-bottom:6px;} }
/* ⚠ while the keyboard is up the dialog's own bottom padding is written by modalFit — do NOT add a
   competing rule here, that is exactly how the pad zone and the window rule fell out of step. */
body.inkKbOn .modal{max-height:100%;}

/* ⭐⭐⭐ v0.611 — THE CHEAT-CODE FLASH. One inverted beat when the line comes back (inkInvertFlash,
   js/online.js). The sheet is white in `mix-blend-mode:difference`, so what animates is only its
   OPACITY — 0 is the page untouched, 1 is the page inverted, and there is nothing in between
   because the timing is `steps(1,end)` at the call site. Two beats, 220ms, then the node removes
   itself.
   ⚠ NO `background:` SHORTHAND and no transform: the element is a flat sheet and must stay one, or
   it stops being a cheap composited quad over a live match. */
@keyframes inkInvFlash{
  0%  { opacity:0 }
  12% { opacity:1 }
  38% { opacity:0 }
  58% { opacity:1 }
  84% { opacity:0 }
  100%{ opacity:0 }
}

/* ============================================================
   ⭐⭐⭐ v0.630 — THE TWELVE EARNED FRAMES + THE THREE BOT RINGS.
   ONE base shape for all of them: `.fr-ring` is the same circle the CLASSIC WHITE frame draws, so
   every frame in the game shares an outline and only its colour and its little moving part differ.
   ⚠ `border-radius:50%`, never `inherit` — see the note higher up this file.
   ⚠ Kept SUBTLE on purpose: a frame sits behind a face on every screen, so nothing here may be
   brighter or faster than the inkling it is framing.
   ============================================================ */
/* ⭐⭐⭐ v0.668 — THE FRAME STAGE. A SQUARE, ALWAYS, WHATEVER BOX IT IS DROPPED INTO.
   (Odaroo, 2026-08-06: "there shouldn't be any oval frames, even on versus and online interactions.
   It should always remain the same circle frames.")
   THE FAULT, and it was geometry rather than art: every frame part below is positioned against its
   CONTAINER (`inset:-3px`, `width:108%`…), and `border-radius:50%` on a box that is not square is an
   ELLIPSE, not a circle. Every avatar box in the game IS square — .hud-av, .fr-prev, .pcard-av,
   .pf-ink, .file-av — with ONE exception: the in-game portrait, `.gink` (and Crown's #crInk/#crFoe),
   which is given a WIDTH and takes its height from the artwork. The inklet is drawn 30×32, so that
   box is 6.7% taller than it is wide and every ring on it came out a tall oval. That is the portrait
   in the top-left and top-right corner of every VS and online screen.
   ⚠⚠ THE BOX ITSELF IS DELIBERATELY NOT SQUARED. vsSlotPlace (js/online.js) measures its `bottom` to
   park the rink's clock and Crown's colour coin, and `.gink svg` is height:auto — forcing a square
   height makes the drawing overflow its own element and silently moves both. So the FRAME gets its
   own square stage instead, centred on the portrait, and nothing that is measured moves a pixel.
   ⚠ NO `transform` and NO `z-index` here, on purpose: either one would make this a stacking context
   and re-order the frame parts against the inklet. `margin-top:-50%` resolves against the WIDTH — the
   square's own height — which is exactly the half-height offset needed, with no stacking context.
   ⚠ It needs a positioned container; every `.gink` is `position:absolute` already. A part injected
   into a STATIC box (Ink-Put's health row) escapes to the nearest positioned ancestor exactly as it
   always did, and that ancestor is square. */
.fr-stage{position:absolute; left:0; top:50%; width:100%; aspect-ratio:1; margin-top:-50%;
  pointer-events:none;}
.fr-ring{position:absolute; inset:-3px; border:3px solid #f6f2e8; border-radius:50%; pointer-events:none; z-index:4;
  box-shadow:0 0 0 1px #1b151033;}
/* the orbit: a full-size circle that SPINS, with the little thing pinned at its top edge */
.fr-orb{position:absolute; inset:-7px; border-radius:50%; pointer-events:none; z-index:5; animation:frOrbit 6s linear infinite;}
.fr-orb>i{position:absolute; left:50%; top:-1px; transform:translateX(-50%); width:5px; height:5px; display:block;
  background:#f4c542; font-family:var(--fontD); font-size:7px; line-height:5px; text-align:center; color:#f4c542;}
.fr-orb.o-b{animation-duration:8.5s; animation-direction:reverse;}
.fr-orb.o-c{animation-duration:11s; animation-delay:-3s;}
@keyframes frOrbit{from{transform:rotate(0)}to{transform:rotate(360deg)}}
/* TETR-INK — falling blocks */
.fr-r-tet{border-color:#5fd6a0; box-shadow:0 0 7px #5fd6a066, 0 0 0 1px #1b151033;}
.fr-orb.o-tet>i{background:#5fd6a0; width:4px; height:4px; box-shadow:0 5px 0 #4fb98a;}
/* ICE-INK — frost creeping round the rim */
.fr-r-rink{border-color:#9fd0ff; box-shadow:0 0 8px #9fd0ff66, inset 0 0 6px #ffffff44, 0 0 0 1px #1b151033;}
.fr-frost{position:absolute; inset:-5px; border-radius:50%; pointer-events:none; z-index:4;
  background:conic-gradient(from 0deg, transparent 0 62%, #dff0ff88 70%, transparent 78% 100%);
  -webkit-mask:radial-gradient(circle, transparent 0 66%, #000 70%); mask:radial-gradient(circle, transparent 0 66%, #000 70%);
  animation:frOrbit 14s linear infinite;}
/* DISC-INK — the dealer's chip edge */
.fr-r-disc{border-color:#f4c542; border-style:dashed; box-shadow:0 0 7px #f4c54255, 0 0 0 1px #1b151033;}
.fr-notch{position:absolute; inset:-1px; border-radius:50%; border:2px solid #ffffff55; pointer-events:none; z-index:4;
  animation:frBreathe 3.2s ease-in-out infinite;}
@keyframes frBreathe{0%,100%{opacity:.35}50%{opacity:.9}}
/* PANEL-INK — sliding panels */
.fr-r-panel{border-color:#c86bd8; box-shadow:0 0 7px #c86bd855, 0 0 0 1px #1b151033;}
.fr-orb.o-pan>i{width:5px; height:5px; background:#c86bd8;}
.fr-orb.o-pan.o-b>i{background:#f4c542;} .fr-orb.o-pan.o-c>i{background:#5fd6a0;}
/* INK-PUT — one arrow chasing its tail */
.fr-r-put{border-color:#ff8a3d; box-shadow:0 0 7px #ff8a3d55, 0 0 0 1px #1b151033;}
.fr-orb.o-put{animation-duration:4.2s;}
.fr-orb.o-put>i{background:none; color:#ff8a3d; width:7px; height:7px; font-size:8px; line-height:7px; top:-4px; text-shadow:0 0 4px #ff8a3d;}
/* BULLET BALLET — tiny shots at speed */
.fr-r-hell{border-color:#e0565f; box-shadow:0 0 7px #e0565f66, 0 0 0 1px #1b151033;}
.fr-orb.o-hell{animation-duration:2.6s;}
.fr-orb.o-hell>i{width:3px; height:3px; background:#ffd9a0; border-radius:50%; box-shadow:0 0 4px #e0565f;}
/* DANCE DANCE INK — the floor lights chase */
.fr-r-ddi{border-color:#7fa8e8; box-shadow:0 0 7px #7fa8e855, 0 0 0 1px #1b151033;}
.fr-chase{position:absolute; inset:-5px; border-radius:50%; pointer-events:none; z-index:4;
  background:conic-gradient(#ff5fd0 0 8%, transparent 8% 25%, #7fa8e8 25% 33%, transparent 33% 50%, #5fd6a0 50% 58%, transparent 58% 75%, #f4c542 75% 83%, transparent 83% 100%);
  -webkit-mask:radial-gradient(circle, transparent 0 68%, #000 72%); mask:radial-gradient(circle, transparent 0 68%, #000 72%);
  opacity:.75; animation:frOrbit 3.4s linear infinite;}
/* INK-TAR HERO — the amp glows on the beat */
.fr-r-ith{border-color:#ff5f3d; box-shadow:0 0 7px #ff5f3d55, 0 0 0 1px #1b151033;}
.fr-amp{position:absolute; inset:-4px; border-radius:50%; border:2px solid #ffb03d; pointer-events:none; z-index:4;
  animation:frAmp 1.15s ease-in-out infinite;}
@keyframes frAmp{0%,100%{opacity:.25; transform:scale(1)}50%{opacity:.85; transform:scale(1.05)}}
/* LUMEN INK — a soft light breathing */
.fr-r-lum{border-color:#ffe9a3; box-shadow:0 0 9px #ffe9a377, 0 0 0 1px #1b151033;}
.fr-halo{position:absolute; inset:-8px; border-radius:50%; pointer-events:none; z-index:3;
  background:radial-gradient(circle, transparent 0 60%, #ffe9a355 74%, transparent 82%); animation:frHalo 3.6s ease-in-out infinite;}
@keyframes frHalo{0%,100%{opacity:.35; transform:scale(.97)}50%{opacity:.9; transform:scale(1.04)}}
/* BIT-BRAWL — the title belt */
.fr-r-brawl{border-color:#f4c542; border-width:4px; box-shadow:0 0 9px #f4c54277, inset 0 0 5px #b8791b66, 0 0 0 1px #1b151033;}
.fr-belt{position:absolute; inset:-3px; border-radius:50%; pointer-events:none; z-index:5;
  border:4px solid transparent; border-image:repeating-conic-gradient(#f4c542 0 6deg, #b8791b 6deg 12deg) 4;
  -webkit-mask:radial-gradient(circle, transparent 0 70%, #000 74%); mask:radial-gradient(circle, transparent 0 70%, #000 74%);
  animation:frOrbit 22s linear infinite;}
/* ⭐⭐⭐ v0.735 — DOWNLOADED (the app frame). Same circle as the rest; the thing that moves is the
   INKVILLE CARTRIDGE — the boot screen's own — orbiting the inklet and turning in 3D as it goes.
   The old pinned download arrow (v0.696) is retired. See frInstCart() in js/rink.js for the markup
   and for why there are three nested spins.
   ⚠⚠ .fr-cartorb AND .fr-cartsat MUST SHARE ONE DURATION (--frOrbMs). The satellite's counter-turn
   is the only thing keeping the cartridge upright as it travels; two different numbers and it
   tumbles slowly round the rim. Change one, change both — that is what the variable is for.
   ⚠ The orbit sits at inset:-9px so the cart rides just OUTSIDE the ring rather than clipping it. */
.fr-r-inst{border-color:#7fe3c8; box-shadow:0 0 8px #7fe3c877, 0 0 0 1px #1b151033;}
.fr-cartorb{--frOrbMs:7s; position:absolute; inset:-9px; border-radius:50%; pointer-events:none; z-index:6;
  animation:frOrbit var(--frOrbMs) linear infinite;}
/* ⚠ THE CART IS SIZED AS A FRACTION OF THE RING, NOT IN PIXELS. This frame is drawn at everything
   from a 30px HUD portrait to an 84px shop tile; a fixed 18px cart is a smudge on one and a lump on
   the other. Percentages resolve against .fr-cartorb's own width, including the margin, so the
   satellite stays centred on the rim at every size. */
.fr-cartsat{position:absolute; left:50%; top:-14%; width:32%; height:32%; margin-left:-16%; display:block;
  animation:frOrbit var(--frOrbMs) linear infinite reverse;}
.fr-ickrig{position:absolute; inset:0; display:block; perspective:120px;
  filter:drop-shadow(0 0 4px #7fe3c8aa);}
.fr-ickspin{position:absolute; inset:0; display:block; transform-style:preserve-3d;
  animation:frIckSpin 3.2s linear infinite;}
.fr-ickface{position:absolute; inset:0; display:block; backface-visibility:hidden; -webkit-backface-visibility:hidden;}
.fr-ickback{transform:rotateY(180deg);}
/* the brand mark: a SIBLING of the spinner, so it can never pick up the turn */
.fr-ickdrop{position:absolute; inset:0; display:block; z-index:3;}
.fr-ickface svg,.fr-ickdrop svg{width:100%; height:100%; display:block;}
@keyframes frIckSpin{from{transform:rotateY(0)}to{transform:rotateY(360deg)}}
@media (prefers-reduced-motion:reduce){ .fr-cartorb,.fr-cartsat,.fr-ickspin{animation:none!important;} }
/* GLITCHED — three rings that cannot agree where the circle is */
.fr-r-glx{border-color:#c93a22; opacity:.8; animation:frglA 1.6s steps(1) infinite;}
.fr-r-glx.b{border-color:#2fa3a0; animation:frglB 2.1s steps(1) infinite;}
.fr-r-glx.c{border-color:#f4c542; opacity:.55; animation:frGlC 2.7s steps(1) infinite;}
@keyframes frGlC{0%,78%{transform:none; opacity:.55}80%,86%{transform:scale(1.06); opacity:.85}88%,100%{transform:scale(.96); opacity:.4}}
/* STELLAR — the last one */
.fr-r-star{border-color:#fff3c4; border-width:4px; box-shadow:0 0 12px #f4c542aa, inset 0 0 7px #ffffff66, 0 0 0 1px #1b151033;
  animation:frAmp 2.6s ease-in-out infinite;}
.fr-halo.st{background:radial-gradient(circle, transparent 0 58%, #f4c54266 74%, transparent 84%);}
.fr-orb.o-star{animation-duration:9s;}
.fr-orb.o-star>i{background:none; color:#fff3c4; width:8px; height:8px; font-size:8px; line-height:8px; top:-4px; text-shadow:0 0 5px #f4c542;}
/* ⚠⚠ v0.653 — THE INVENTED BOT RINGS (.fr-r-alpha/.fr-r-beta/.fr-r-omega) ARE DELETED. The
   golden rule is that the ONLY frames are the ones in FRAMES; ALPHA, BETA and OMEGA now wear
   frdisc / frbrawl / frstellar, which are real, locked, and already styled above. Do not add a
   ring here for anything that is not a FRAMES entry. */

/* ============================================================
   ⭐⭐⭐ v0.635 — THE SYMMETRICAL 4×4 SHELF (Odaroo, 2026-08-05).
   ONE grid serves the Mart's three tabs (GEAR · COSTUMES · FRAMES) and the profile rack, so the
   two screens read as the same furniture. Four across, square cells, art only — no names, no
   prices, no stat tags on the tile itself. What a thing costs and what it does is the detail card.
   ⚠⚠ IT IS FOUR ACROSS ON BOTH FOLDS ON PURPOSE. The old rack widened to six at 640px, which made
   the unfolded screen a DIFFERENT shape rather than a bigger one; the cells simply grow instead,
   capped so a wide screen does not draw postage-stamp art in a sea of gap.
   ============================================================ */
.ivgrid{display:grid; grid-template-columns:repeat(4,1fr); gap:8px; align-content:start;
  max-width:520px; margin:0 auto; padding:6px 2px 10px;}
.ivtile{position:relative; aspect-ratio:1; padding:0; cursor:pointer; display:flex;
  align-items:center; justify-content:center; border-radius:9px;
  background:linear-gradient(#241019,#160a10); border:2px solid #3a1420;
  transition:transform .1s, border-color .1s, box-shadow .1s;}
.ivtile:active{transform:scale(.94);}
.ivtile.own{border-color:#6b3a4c;}
.ivtile.worn{border-color:var(--gold); box-shadow:0 0 10px #f4c54255;}
.ivtile.hole{background:#12070d; border-style:dashed; border-color:#2a0f18; cursor:default; box-shadow:none;}
.ivtile.hole:active{transform:none;}
.ivtile-art{width:78%; height:78%; display:flex; align-items:center; justify-content:center; position:relative;}
.ivtile-art svg{width:100%; height:100%; image-rendering:pixelated;}
.ivtile-art .fr-prev{width:100%; height:100%; position:relative;}
.ivdot{position:absolute; top:4px; right:4px; width:8px; height:8px; border-radius:50%;
  background:#5a5a66; border:1px solid #0008;}
.ivdot.on{background:var(--gold); box-shadow:0 0 6px var(--gold);}
/* the profile rack keeps its shelf board under every row */
.ivgrid.rack .ivtile::after{content:''; position:absolute; left:-6px; right:-6px; bottom:-7px; height:6px;
  background:linear-gradient(#7a5433,#54371f); border-top:2px solid #93693f; border-radius:2px; pointer-events:none;}
.ivgrid.rack{row-gap:16px;}
/* ===== THE DETAIL CARD — name · cost · effect. NO combat stats (they arrive with Bit-Brawl). ===== */
.ivcard{width:min(280px,74vw);}
.ivcard-nm{font-size:13px; letter-spacing:.5px;}
.ivcard-art{width:76px; height:76px; margin:10px auto 8px;}
.ivcard-art svg{width:100%; height:100%; image-rendering:pixelated;}
.ivcard-row{display:flex; align-items:baseline; justify-content:space-between; gap:10px;
  margin:5px 6px; padding:5px 8px; border-radius:6px; background:#0000002e; text-align:left;}
.ivcard-row>span{font-family:var(--fontD); font-size:8px; letter-spacing:1px; opacity:.7; flex:0 0 auto;}
.ivcard-row>b{font-size:11px; line-height:1.5; text-align:right;}
.ivcard-note{opacity:.7; margin:2px 8px 8px;}

/* ===== ⭐⭐⭐ v0.700 — THE GLITCH CART RIG (shared: the boot screen AND the install invite) =====
   Markup comes from inkCartRig() in js/ui.js and nowhere else. The shell spins in 3D and glitches;
   the ink drop sits over the axis, flat and still and CLEAN — no rotation, no chroma, no jitter.
   ⚠ It lives in base.css on purpose: it is shown from two different sheets' screens (home.css's
   boot splash and hub.css's install card) and must look identical in both, so neither of them may
   own it. Those sheets size the BOX; this owns what is inside it. */
.ickrig{position:absolute; inset:0; perspective:340px;}
.ick-spin{position:absolute; inset:0; transform-style:preserve-3d;
  animation:ickSpin 3.6s linear infinite, ickJolt 4.7s steps(1) infinite;}
@keyframes ickSpin{from{transform:rotateY(0deg)}to{transform:rotateY(360deg)}}
/* the shell's own glitch, and it is ONLY the shell's: a chroma tear and a one-frame slip.
   ⚠ ickJolt rides `translate`, NOT `transform` — ickSpin already owns transform on this element and
   a second `transform` rule would REPLACE the rotation rather than add to it (the shorthand trap). */
@keyframes ickJolt{0%,90%,100%{translate:0 0}92%{translate:2px -1px}94%{translate:-3px 1px}96%{translate:1px 0}}
.ick-face{position:absolute; inset:0; backface-visibility:hidden; -webkit-backface-visibility:hidden;}
.ick-back{transform:rotateY(180deg);}   /* a cartridge's real reverse, never a mirror of its front */
.ick-face svg{width:100%; height:100%; display:block; animation:ickRGB 1.1s steps(2) infinite;}
@keyframes ickRGB{0%,100%{filter:drop-shadow(0 0 4px #bfe6ff88)}
  30%{filter:drop-shadow(-1.6px 0 #e2426b) drop-shadow(1.6px 0 #37d8f0)}
  55%{filter:drop-shadow(1.6px 0 #37d8f0)}
  80%{filter:drop-shadow(0 0 4px #bfe6ff88)}}
.ick-shine{opacity:.3; animation:ickShine 2.4s ease-in-out infinite;}
@keyframes ickShine{0%,100%{transform:translateX(0); opacity:.15}50%{transform:translateX(20px); opacity:.7}}
/* ⚠⚠ THE DROP. A SIBLING of the spinner, never a child — nothing added to .ick-spin later can
   reach it. It carries no animation of any kind and it must stay that way. */
.ick-drop{position:absolute; inset:0; z-index:3; pointer-events:none;}
.ick-drop svg{width:100%; height:100%; display:block; filter:drop-shadow(0 0 6px #bfe6ff88);}
/* ⚠ SEATED: the tap turns it square-on and the glitch stops dead, so it goes into the slot clean. */
.ickrig.seat .ick-spin{animation:none!important;}
.ickrig.seat .ick-face svg{animation:none!important; filter:drop-shadow(0 0 4px #bfe6ff88)!important;}
@media (prefers-reduced-motion:reduce){
  .ick-spin,.ick-face svg,.ick-shine{animation:none!important;}
}

/* ============================================================
   THE MASCOT — sprites/inklet.png                                   (v0.760)
   ------------------------------------------------------------
   Every inklet in the game is this one picture, painted in the player's colour by inkTint()
   (js/core.js) and handed back by inkletSvg() as an <svg><image>.

   ⚠⚠ THE BREATH IS ON THE INNER <image>, NOT ON THE <svg>. The svg already carries
      `.gink>svg{transform:scale(.75)}`, `.gink.hype>svg` combo pops and — on the glitch ink — an
      INLINE `filter`/`translate` from inkGlitchPhase(). A CSS animation outranks an inline style,
      so breathing the outer svg would have stopped the glitch jitter dead and fought the combo pop
      for the same property. The two layers each keep their own.
   ⚠⚠ IT IS `scale` + `translate`, THE INDEPENDENT PROPERTIES, NOT A `transform` SHORTHAND — the
      same rule as everywhere else in this project: a second `transform` REPLACES the first.
   ⚠ `transform-box:fill-box` is what makes the origin mean the picture rather than the whole svg
     user space; without it the drop swells away from the corner and slides across its own frame.
   ⚠ NO frame-by-frame sheet: this is a still, and it is alive because it breathes and hops.
   ⚠⚠⚠ THE KEYFRAMES ARE `inkletBreathe`, NOT `inkBreathe` — THAT NAME IS TAKEN. `css/menu.css`
      already declares `@keyframes inkBreathe` (an OPACITY fade, for .inkspot-glow) and it loads
      AFTER this file, so the later declaration wins outright and the mascot merely faded in and
      out instead of breathing. Same family as the `#toast` font-size trap in CLAUDE.md: a keyframe
      name is global across every stylesheet, and the last one to be parsed is the only one there
      is. Never name keyframes after the thing they animate without grepping css/ for the name.
   ============================================================ */
.inkpng{display:block; overflow:visible;}
/* ⚠⚠⚠ THE MASCOT IS **NOT** `image-rendering:pixelated`, AND THAT IS THE WHOLE POINT (v0.760).
   The instinct is right everywhere else in this game and wrong here, for one measurable reason:
   THE PICTURE IS 901x683 AND IS NEVER DRAWN LARGER THAN ~300px. It is ALWAYS being shrunk, and
   `pixelated` on a downscale is nearest-neighbour — at HUD-avatar size (~66px) it throws away 92%
   of the pixels and picks the survivors on a grid, which frays the outline into a ragged, uneven
   line and speckles the blush marks. Measured side by side at 375x812: smooth is cleanly outlined,
   pixelated is broken.
   ⚠ IT STILL LOOKS LIKE PIXEL ART. The chunk comes from the SOURCE — inklet.png is itself drawn in
     big square pixels — so smoothing the downscale preserves the blocks and only removes the
     sampling noise. `pixelated` would earn its keep if the drop were ever drawn ABOVE its native
     size; nothing in the game does, so it costs aliasing everywhere and buys nothing anywhere.
   If a future screen DOES blow the mascot up past ~900px, give THAT screen `pixelated`, not this. */
.inkpng-i{
  image-rendering:auto;
  transform-box:fill-box; transform-origin:50% 92%;   /* anchored at its feet, so it never drifts */
}
  /* ⭐⭐⭐ v0.832 — THE BREATH IS NO LONGER RUN FROM THE <image>. IT MOVED UP ONTO THE <svg>.
     ------------------------------------------------------------
     Odaroo, after v0.831: "I'm still noticing the flickering on the Fold 8, even during end game."
     He was right again, and this was it — nothing to do with resize, which is why every previous
     hunt missed it.

     MEASURED (tools/probe-flicker.mjs, tools/test-idle-cost.mjs): the game was forcing a FULL PAGE
     LAYOUT EVERY FRAME — 60 a second, on every screen in the game, for the entire session, with
     nobody touching it. One animation was responsible: this one.

     ⚠⚠⚠ AN ANIMATED **SVG CHILD** CAN NEVER BE COMPOSITED IN BLINK. `scale`/`translate` are the
     independent transform properties and on an HTML element they run on the compositor for free —
     but on an element INSIDE an <svg> there is no layer to hand to the compositor, so every frame
     goes back through layout. `will-change`, `contain:layout paint` and `contain:strict` were all
     measured on the <image> and the parent and NONE of them help: it is not a containment problem,
     it is that the element cannot be promoted at all. Moving the same keyframes one level up, onto
     the <svg> (a replaced element, which CAN be promoted), takes it to ZERO layouts a second with
     the animation running identically. That is the whole fix.
     ⚠ Anything else that wants to animate a mascot must animate the <svg> or a DIV — never a shape,
     <image>, <g> or <path> inside one. That is now a rule, not a preference. */
/* ⭐ v0.832 — and here it is, on the element that can carry it. See the long note above.
   ⚠⚠ IT USES THE INDEPENDENT `scale`/`translate` PROPERTIES AND MUST KEEP DOING SO. `svg.inkpng`
      already carries `transform:scale(.75)` from css/profile.css, and `transform` is a single
      property — keyframes written with `transform` would REPLACE that shrink and every inklet in
      the game would jump to full size. The independent properties multiply into it instead, which
      is the same composition the rest of this file relies on.
   ⚠ `will-change` is load-bearing, not a hint: without it the measurement is 18 layouts a second
      rather than 0, because the element is only promoted once the compositor decides to. */
.inkpng{animation:inkletBreathe var(--inkbreath,2.9s) ease-in-out infinite; will-change:transform;}
/* the stress cue on the portrait itself — the sweat bead is DRAWN into the face (ifpSweat,
   js/face.js); this is only the faint shiver that goes with it.
   ⚠ It animates `rotate`, an independent property, because `.gink.hype` already owns `transform`
   for the combo pop and a second transform would replace it. */
/* ⚠⚠⚠ v0.832 — AND IT HAS TO RESTATE THE BREATH. This selector is (0,2,1) and `.inkpng` above is
   (0,1,0), so now that the breath lives on the <svg> too, an `animation:inkShiver` here would
   REPLACE it outright and a stressed mascot would stop breathing — the exact shorthand trap this
   file warns about three times over, arriving from a new direction the moment the breath moved. */
.gink.inkstress>svg{animation:inkletBreathe var(--inkbreath,2.9s) ease-in-out infinite, inkShiver .5s ease-in-out infinite;}
@keyframes inkShiver{ 0%,100%{rotate:-.6deg;} 50%{rotate:.6deg;} }
@media (prefers-reduced-motion:reduce){ .gink.inkstress>svg{animation:none;} }
@keyframes inkletBreathe{
  0%,100%{ scale:1 1;        translate:0 0; }
  28%    { scale:1.025 .975; translate:0 6px; }       /* the squash, on the way down */
  55%    { scale:.985 1.035; translate:0 -9px; }      /* the stretch, at the top of the hop */
  78%    { scale:1.01 .99;   translate:0 2px; }
}
@media (prefers-reduced-motion:reduce){
  /* v0.832 — follows the breath onto the <svg>; on the <image> this now switches off nothing. */
  .inkpng{animation:none;}
}

/* ⭐⭐⭐ v0.879 — THE FACE PARALLAX. The mascot is two layers now (js/body.js draws the body,
   js/face.js draws the face over it) and Odaroo asked for the face to lag a little behind the body
   as it breathes — the small offset that makes two flat cut-outs read as one rounded 3D thing.
   ⚠⚠⚠ IT IS `steps()`, AND THAT IS NOT A STYLE CHOICE — IT IS THE ONLY WAY THIS IS AFFORDABLE.
      `.inkface` is a <g> INSIDE an <svg>, and the v0.832 note 40 lines above this one is the whole
      story: such an element can never be promoted to a compositor layer in Blink, so a SMOOTH
      animation on it costs a FULL PAGE LAYOUT EVERY FRAME — measured at 60/s for one <image>, on
      every screen, which is the fault that took five builds to find. Stepping it means the face
      jumps four times per breath instead of moving sixty, i.e. ~1.4 layouts a second rather than
      60. That is the same bargain `pkgSlice` (css/wjam.css) already lives by and is recorded in
      CLAUDE.md as negligible.
      ⚠ IF THIS EVER NEEDS TO BE SMOOTH, the face has to become a TOP-LEVEL element — its own
        <svg> or a <div> over the body — not a smoother animation on this one. Do not simply drop
        the `steps()`; measure it with `node tools/test-idle-cost.mjs` first and watch it fail.
   ⚠⚠ THE OFFSET IS IN **USER UNITS**, NOT CSS PIXELS. This is an SVG child, so `translate` resolves
      against the viewBox (871 wide) rather than against the screen — the `6px` the breath uses on
      the <svg> would be a quarter of one percent here and invisible. These numbers are ~0.8% of
      the mascot, which is the whole intended effect: felt, not seen.
   ⚠⚠ IT OPPOSES THE BREATH, WHICH IS WHAT MAKES IT A PARALLAX. The face is inside the body's own
      transform, so it already moves WITH it; a nudge the same way would only exaggerate the breath.
      Moving slightly against it is the layer travelling LESS than the one behind, which is what the
      eye reads as depth.
   ⚠ `translate`, the independent property — never `transform`. Nothing else owns transform on this
      group today, but the wardrobe's groups are its siblings and this project has been bitten by
      the shorthand seven times. */
.inkface{ animation:inkFaceLag var(--inkbreath,2.9s) steps(1,end) infinite; }
@keyframes inkFaceLag{
  0%,100%{ translate:0 0; }
  28%    { translate:2 -5; }        /* the body squashes down — the face has not caught up */
  55%    { translate:-3 7; }        /* the body stretches up — the face trails below */
  78%    { translate:1 -2; }
}
/* ⚠ A STILL MASCOT IS A FINE MASCOT. Unlike the breath — which js/ui.js's SMOOTH MODE deliberately
   exempts, because a mascot that has stopped breathing looks broken — this is pure decoration, so
   it is allowed to switch off. The rule is re-emitted under `html.lowmo` for free (inkLowMo). */
@media (prefers-reduced-motion:reduce){ .inkface{animation:none;} }
