Streamline mobile menus and refresh splash logo

This commit is contained in:
wuwi committed 2026-09-07 13:26:17 +02:00
1 parent a399e4929c
commit a09856f8c9
6 files changed
+569 -483

No files matched your search

+243 -210
View File
@@ -117,6 +117,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
system font and drew a smooth glyph inside a pixel HUD */
color: var(--cream); font: 700 14px Silkscreen, monospace; line-height: 1;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
#qChip::after { content: ""; position: absolute; top: -4px; right: -4px; min-width: 44px; min-height: 44px; width: 100%; height: 100%; }
#quit button:active { background: var(--red); border-color: var(--bad); color: #f2ead6; }
#qAsk { position: fixed; inset: 0; z-index: 6; background: rgba(0,0,0,0.55);
display: flex; align-items: center; justify-content: center; }
@@ -165,10 +166,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#hot { position: fixed; top: 0; left: 0; width: 26vw; max-width: 150px; height: 15vh;
max-height: 130px; z-index: 4; }
#intro .card { font: 400 12px Silkscreen, monospace; color: var(--cream); text-align: center;
line-height: 1.9; padding: 16px 14px 20px; width: min(370px, 100vw - 28px); /* CONTENT width (content-box) beside 14px+14px padding:
94vw put the card 5-28px past the viewport on every phone (0.94w+28 > w below
466px) and #intro's overflow-y:auto computes overflow-x to auto — that was the
sliver of horizontal scroll Oliver caught on the splash */
line-height: 1.9; width: 100%; min-width: 0; box-sizing: border-box;
/* Padding belongs inside the full-width card, including on wider preview panes. */
animation: drop 0.5s ease-out; }
@keyframes drop { from { transform: translateY(-14px); opacity: 0; } }
/* EVERY SCREEN FILLS THE VIEWPORT, and every screen is the same three bands: something pinned to
@@ -180,9 +179,27 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro .card { display: flex; flex-direction: column; min-height: 100dvh; padding: 0 14px; }
/* the slack band: the title sits in the middle of whatever is left over */
.mid { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 12px 0; }
/* the logo is generated pixel art (assets/logo.png), not stacked CSS text */
#intro .logo { display: block; width: min(300px, 74vw); height: auto; margin: 0 auto 6px;
image-rendering: pixelated; filter: drop-shadow(0 3px 0 rgba(0,0,0,0.45)); }
/* the logo is generated pixel art (assets/logo-v2.png), not stacked CSS text */
#intro .logoArt { position: relative; width: min(300px, 74vw); margin: 0 auto 6px; }
#intro .logo { display: block; width: 100%; height: auto;
image-rendering: pixelated; filter: saturate(.78) brightness(.84) drop-shadow(0 3px 0 rgba(0,0,0,0.45)); }
.logoArt::after { content: ''; position: absolute; pointer-events: none; opacity: 0; }
/* The drop follows the blood underline in logo-v2.png at every viewport size. */
.logoArt::after { left: 62%; top: 94%; width: 1.3%; height: 5%;
background: #861d18; border-radius: 35% 35% 55% 55%; transform-origin: top;
box-shadow: inset 1px 0 rgba(196,69,48,.45); }
#intro:not([hidden])[data-stage="splash"] #splash:not(.busy) .logoArt::after {
animation: logoBloodDrop 14s 4s infinite; }
@keyframes logoBloodDrop {
0% { opacity: 0; transform: scale(.5, .15); }
3% { opacity: .9; transform: scale(.8, .5); }
10% { opacity: .9; transform: translateY(2px) scale(1, 1.1); animation-timing-function: ease-in; }
14% { opacity: .8; transform: translateY(12px) scale(.7, 1.3); }
17%, 100% { opacity: 0; transform: translateY(20px) scale(.6, .8); }
}
@media (prefers-reduced-motion: reduce) {
#intro:not([hidden])[data-stage="splash"] #splash:not(.busy) .logoArt::after { animation: none; }
}
/* the slogan carries most of the gap between the logo's blood and the first button */
#intro .slogan { margin: 8px 0 20px; font-size: 11px; color: #a8a09e; letter-spacing: 0.5px; }
#intro .slogan b { color: #c9564a; }
@@ -191,13 +208,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #vs, #train, #player, #promo, #card { display: none; }
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #vs, #train, #player, #promo, #card { display: none; }
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
screen whose content does not fill it */
#intro[data-stage="setup"] #setup,
#intro[data-stage="mode"] #stmode,
#intro[data-stage="modesub"] #stsub,
#intro[data-stage="stats"] #stats,
#intro[data-stage="ranks"] #ranks,
#intro[data-stage="secret"] #secret,
@@ -221,18 +237,13 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro.deal > .card > div:not(#splash) > *:nth-child(3) { animation-delay: 0.16s; }
#intro.deal > .card > div:not(#splash) > *:nth-child(4) { animation-delay: 0.23s; }
#intro.deal > .card > div:not(#splash) > *:nth-child(n+5) { animation-delay: 0.30s; }
/* the poster wall deals its ROOMS, not one lump: the list is the whole screen there, so letting
it arrive as a single band wastes the entrance. The list itself stops animating — a moving
child inside a moving parent starts at twice the offset and reads as a stutter. */
#intro.deal #stmode > #modeList { animation: none; }
#intro.deal #modeList > .mgroup {
animation: dealIn 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; }
#intro.deal #modeList > .mgroup:nth-child(1) { animation-delay: 0.09s; }
#intro.deal #modeList > .mgroup:nth-child(2) { animation-delay: 0.17s; }
#intro.deal #modeList > .mgroup:nth-child(3) { animation-delay: 0.25s; }
#intro.deal #modeList > button { animation: dealIn 0.28s ease-out backwards; }
#intro.deal #modeList > button:nth-child(2) { animation-delay: 0.07s; }
#intro.deal #modeList > button:nth-child(3) { animation-delay: 0.14s; }
@media (prefers-reduced-motion: reduce) {
#intro.deal > .card > div:not(#splash) > *,
#intro.deal #modeList > .mgroup { animation: none; } }
#intro.deal > .card > div:not(#splash) > *, #intro.deal #modeList > button { animation: none; }
}
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
display:grid above would otherwise win over the hidden attribute */
#intro[hidden] { display: none; }
@@ -258,7 +269,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
.hdr h2 { padding-left: 2px; } /* (its face is set with the other titles above) */
/* UPRIGHT, not Bangers: the announcement face is drawn slanted, and a slanted arrow read as
italic on every screen. Silkscreen's ASCII < > are straight pixel arrows. */
.chev { width: 34px; height: 34px; flex: none; background: none; border: 2px solid var(--line);
.chev { width: 44px; height: 44px; flex: none; background: none; border: 2px solid var(--line);
color: var(--acid); font: 700 14px Silkscreen, monospace; line-height: 1; cursor: pointer;
-webkit-tap-highlight-color: transparent; }
/* the BODY COUNT headline row: three boxed figures sharing one frame */
@@ -304,27 +315,27 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* the secondary doors NAME (Jersey) and carry a NUMBER (Silkscreen, acid) — the text reduction
turned "RANKINGS / WORLD BOARD" into "BOARD / BEST 612": a label and a fact, no description */
.btn.sec { font: 400 18px/1.2 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
#qNo { font: 400 17px/1.2 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
#qYes { font: 400 17px/1.2 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
.btn.sec i { display: block; margin-top: 3px; font: 700 10px Silkscreen, monospace;
font-style: normal; color: var(--acid); letter-spacing: 1px; }
/* THE PLAYER BLOCK: the live man on the left, everything that changes him on the right, so a tap
and its result are never more than a thumb apart and the whole of "who am I" is one screenful. */
#who, #who2 { display: flex; gap: 12px; align-items: flex-start; }
#whoCtl, #pCtl { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
#who2 { display: flex; gap: 12px; align-items: flex-start; }
#pCtl { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
/* The live player. `position: static` is not decoration: the bare `canvas` rule above is the COURT
— fixed, full-bleed, 100dvh — and it catches any canvas in the document, so this one flew to the
top-left corner of the screen until it was told otherwise. Every geometry property that rule
sets has to be undone here. The buffer is the sprite's own 96x224, so width follows height. */
#look, #pLook { position: static; display: block; flex: none; margin: 0;
#pLook { position: static; display: block; flex: none; margin: 0;
width: auto; height: 176px; touch-action: auto; image-rendering: pixelated; }
/* the tour is now TWO WORDS, not two portraits — the live player next to them is the picture, and
a second drawing of the same man (front-facing, and deaf to every colour on this screen) was
both a duplicate and the reason the court list started below the fold */
#tours, #pTours { display: flex; gap: 7px; }
#tours button, #pTours button { flex: 1; padding: 10px 2px; background: var(--panel); border: 2px solid var(--line);
#pTours { display: flex; gap: 7px; }
#pTours button { flex: 1; padding: 10px 2px; background: var(--panel); border: 2px solid var(--line);
color: var(--dim); font: 400 10px Silkscreen, monospace; letter-spacing: 1px;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
#tours button.on, #pTours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); color: var(--cream); }
#pTours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); color: var(--cream); }
/* THE IDENTITY SCREEN (the leagues, phase 1): name row = the input plus the die. */
#nameRow { display: flex; gap: 8px; }
#nameRow .tin { flex: 1; margin: 0; }
@@ -538,110 +549,57 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
text-decoration: none; letter-spacing: 0.5px; }
/* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
/* MODE posters are pure BUTTONS, not selectors (Oliver: the remembered highlight made each one
"a checkbox and a button combined"). Every card is full-bright and equal because every card is
a live answer to the header's question; tapping answers and advances. The only memory shown is
the LAST tag — an indicator, not a selection. A border that means "SERVE UP will use this"
exists solely on the final screen, where a confirm button actually follows. */
#modeList button.mode, #modeSub button.mode { display: flex; align-items: flex-end; width: 100%; min-height: 96px;
margin: 0 0 8px; padding: 7px 9px; background-size: cover; background-position: center bottom;
image-rendering: pixelated; border: 2px solid var(--line); color: inherit; font: inherit;
text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
position: relative; }
/* THE CAPTION'S VEIL (2026-08-31). The blur went in and came straight back out — on
nearest-neighbour art it read as a smear, not as depth (Oliver: "sieht komisch aus"). What is
left is what he asked for instead: a gradient RISING FROM THE BOTTOM EDGE, solid where the
words are and gone by the time it reaches the art. Same job, no filter, no edge. */
#modeList button.mode::after, #modeSub button.mode::after {
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 62%;
background: linear-gradient(0deg, rgba(6,10,8,0.94) 0%, rgba(6,10,8,0.86) 22%,
rgba(6,10,8,0.5) 58%, rgba(6,10,8,0) 100%);
pointer-events: none; }
/* the words and the chips ride ABOVE the veil — a pseudo-element is the last thing painted */
#modeList .cb, #modeSub .cb { position: relative; z-index: 2; }
#modeList b, #modeSub b { text-shadow: 0 1px 0 #000, 0 0 6px rgba(0,0,0,0.9); }
/* the subtitle carries the mode's one-line rules over busy art, so it gets WHITE at full
opacity, a size up from the court rows' 9px, and a solid black underlay — "barely readable"
at cream-on-crowd was the report from the first live look */
#modeList i, #modeSub i { color: #fff; font-size: 10px; margin-bottom: 0;
text-shadow: 0 1px 0 #000, 1px 0 0 #000, -1px 0 0 #000, 0 -1px 0 #000, 0 0 7px #000; }
/* THE SECTIONS (THE SEASON): the wall is three rooms — one path up, a room to practise in, a
room where nothing counts — so each is a real section with its posters INSIDE it, not a label
floating over a stack. The heading is deliberately NOT the announcement face (Oliver: it read
as a second card headline and the two competed); Silkscreen, big and letter-spaced, keeps it
a label of a room. No rule, no left band either — the air above each heading does the
grouping, which is what air is for. */
#modeList .mgroup { margin: 0 0 10px; }
#modeList .mgroup + .mgroup { margin-top: 17px; }
/* HOW A ROOM DEALS ITS POSTERS (the text reduction). The wall used to be six identical 148px
slabs and ran over two screens; the fixture is a HERO now, the training pair stands side by
side, and the casual three stack shorter. All of it fits one screen. */
#modeList .mgroup .shelf { display: flex; flex-direction: column; }
#modeList .mgroup.pair .shelf { flex-direction: row; gap: 8px; }
#modeList .mgroup.pair button.mode { flex: 1; min-width: 0; min-height: 106px; margin-bottom: 0; }
#modeList .mgroup.stack button.mode { min-height: 96px; }
#modeList .mgroup.hero button.mode { min-height: 142px; }
/* the poster names: Jersey NAMES, and the hero is the one poster that CALLS (Bangers) */
#modeList .cb b { font: 400 30px/1 'Jersey 15', Bangers, cursive; letter-spacing: 0.5px; }
#modeList .mgroup.pair .cb b { font-size: 27px; }
#modeList .mgroup.hero .cb b { font: 400 32px/0.95 Bangers, cursive; letter-spacing: 1px; }
/* ...and the hero's second line is the fixture, the only strap left on the wall */
#modeList .mgroup.hero .cb i { color: #f0cfc4; font-size: 10px; margin-top: 3px; }
/* text-align rides down from the card, which centres everything on these screens — the section
header is a header, and headers on this screen are left-aligned (the .hdr title, the posters'
own captions) */
#modeList .mgroup > header { margin: 0 0 11px; text-align: left; }
/* THE SHELF LABELS SHOUT, quietly (2026-08-31, Oliver tried three: Bangers first, then the HUD
face, then the platform's UI face, and came back to the announcement face). It works here now
because the header lost its rule and its band and gained air: the size does the hierarchy
(20 against the screen title's 26) instead of the typeface having to. */
/* SCOPED TO THE HEADER, not to the section: the posters live INSIDE .mgroup now, so a bare
descendant selector here restyled every caption on the wall (it stayed invisible while this
rule still named Silkscreen — the moment the family changed, the posters changed with it). */
#modeList .mgroup > header b { display: block; letter-spacing: 0.5px; color: var(--acid);
text-shadow: none; font: 400 17px/1.2 'Jersey 15', Bangers, cursive;
letter-spacing: 2px; }
/* ...and the strap goes back to the quiet register under it: a shouted line over a system-sans
line was two foreign voices in one header */
#modeList .mgroup > header i { display: block; margin: 4px 0 0; letter-spacing: 0.5px;
color: var(--dim); font-style: normal; text-shadow: none;
font: 400 9px/1.4 Silkscreen, monospace; }
#modeList .mgroup button.mode:last-child { margin-bottom: 0; }
/* THE SECOND STEP (config.MENU) has its own SCREEN in the wizard: sub-mode cards are
full-height posters centred on their scene, and the difficulty chips are plain acid-outline
buttons stacked tall — art for EASY/MEDIUM/HARD would be forced, and the chip reads faster. */
/* alone on their own screens, the posters get room to be posters — and the first card clears
the header by the same breath the h3 rows used to give the old single page */
#stmode #modeList, #stsub #modeSub { margin-top: 12px; }
#stmode button.mode, #modeSub button.mode { min-height: 148px; }
#modeSub { display: flex; flex-wrap: wrap; gap: 8px; }
#modeSub button.mode { background-position: center; }
#modeSub button.chip { flex: 1 1 100%; display: flex; align-items: center; justify-content: space-between;
gap: 10px; padding: 26px 16px; background: var(--panel); border: 2px solid var(--line);
color: var(--cream); cursor: pointer; -webkit-tap-highlight-color: transparent;
position: relative; text-align: left; }
#modeSub button.chip b { font: 400 30px/1 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
/* how hard it is, counted: as many pips lit as the chip is steep, and the last one wears the
colour the game uses for a threat */
.pips { display: flex; gap: 5px; }
.pips i { width: 10px; height: 10px; background: rgba(255,255,255,0.12); }
.pips i.on { background: var(--acid); }
#modeSub button.chip.hot .pips i.on { background: var(--bad); }
#courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%;
/* The additional modes fit in two rows; the friend has a door of its own on the splash. */
#modeList { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 18px 0; }
#modeList button.mode { display: flex; align-items: flex-end; position: relative; min-width: 0;
min-height: 150px; padding: 12px; border: 2px solid var(--line); color: var(--cream);
background-size: cover; background-position: center; image-rendering: pixelated;
text-align: left; cursor: pointer; }
#modeList button.mode:last-child { grid-column: 1 / -1; }
#modeList button.mode::after { content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(0deg, rgba(6,10,8,.94), rgba(6,10,8,.2) 65%, transparent); }
#modeList .cb { position: relative; z-index: 1; }
#modeList .cb b { font: 400 29px/1 'Jersey 15', cursive; letter-spacing: .5px; }
.playerLink { display: flex; align-items: center; justify-content: space-between; gap: 12px;
width: 100%; min-height: 60px; margin: 16px 0 0; padding: 10px 12px; text-align: left;
background: var(--panel); border: 2px solid var(--line); color: var(--cream); cursor: pointer; }
.playerLink b { display: block; font: 22px/1 'Jersey 15', cursive; }
.playerLink i { display: block; margin-top: 5px; font: 9px Silkscreen, monospace;
color: var(--dim); font-style: normal; }
.playerLink u { font: 17px 'Jersey 15', cursive; text-decoration: none; color: var(--acid); }
.opponentRow { display: flex; align-items: center; gap: 14px; margin-top: 18px; text-align: left; }
.opponentRow img { height: 50px; width: 48px; object-fit: contain; image-rendering: pixelated; }
.opponentRow b { display: block; font: 24px 'Jersey 15', cursive; }
.opponentRow i { font: 10px Silkscreen, monospace; font-style: normal; color: var(--dim); }
.setupNote { margin: 18px 0 0; font: 10px/1.6 Silkscreen, monospace; color: var(--dim); }
#sDifficulty[hidden], #sOpponent[hidden], #sClimb[hidden], #lockedCourts[hidden] { display: none; }
#difficultyList { margin: 0; }
#difficultyList button { font: 20px 'Jersey 15', cursive; }
.disclosure { margin: 14px 0; text-align: left; border-top: 1px solid var(--line); }
.disclosure > summary { display: flex; align-items: center; justify-content: space-between;
min-height: 48px; gap: 10px; list-style: none; cursor: pointer; color: var(--cream);
font: 18px/1.2 'Jersey 15', cursive; letter-spacing: .5px; }
.disclosure > summary::-webkit-details-marker { display: none; }
.disclosure > summary::after { content: '+'; color: var(--acid); font: 16px Silkscreen, monospace; }
.disclosure[open] > summary::after { content: '−'; }
#cdStats { display: flex; justify-content: space-between; align-items: center; margin: 16px 0 0;
min-height: 52px; border-color: #627340; color: var(--acid); text-align: left; font-size: 23px; }
#pNote[hidden] { display: none; }
:is(#courtList, #lockedCourtList) button.court { display: flex; align-items: center; gap: 11px; width: 100%;
margin: 0 0 8px; padding: 7px; background: var(--panel);
border: 2px solid var(--line); color: inherit; font: inherit; text-align: left;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
#courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
#courtList img { width: 84px; height: 60px; display: block; image-rendering: pixelated; flex: none;
:is(#courtList, #lockedCourtList) button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
:is(#courtList, #lockedCourtList) img { width: 84px; height: 60px; display: block; image-rendering: pixelated; flex: none;
object-fit: cover; }
#courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; }
#courtList b, #modeList b, #modeSub b { display: block; font: 700 15px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
:is(#courtList, #lockedCourtList) .cb, #modeList .cb { flex: 1; min-width: 0; }
:is(#courtList, #lockedCourtList) b, #modeList b { display: block; font: 700 15px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
/* the court NAMES itself (Jersey) and its two meters say the rest — the description line is gone */
#courtList b { font: 400 22px/1.1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
#courtList i { margin: 3px 0 5px; font: 700 9px Silkscreen, monospace; color: var(--bad);
:is(#courtList, #lockedCourtList) b { font: 400 22px/1.1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
:is(#courtList, #lockedCourtList) i { margin: 3px 0 5px; font: 700 9px Silkscreen, monospace; color: var(--bad);
letter-spacing: 1px; }
#courtList i[hidden] { display: none; }
#courtList i, #modeList i, #modeSub i { display: block; margin: 4px 0 6px; font: 400 10px Silkscreen, monospace; color: #c9c4b8; font-style: normal; }
:is(#courtList, #lockedCourtList) i[hidden] { display: none; }
:is(#courtList, #lockedCourtList) i, #modeList i { display: block; margin: 4px 0 6px; font: 400 10px Silkscreen, monospace; color: #c9c4b8; font-style: normal; }
/* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot
drift away from what the ball actually does on it. */
.meter { display: flex; align-items: center; gap: 6px; margin-top: 4px;
@@ -661,21 +619,105 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
z-index: 5; color: #ffd8ca; background: #59251f; padding: 4px 6px;
font: 10px Silkscreen, monospace; pointer-events: none; }
#recLamp[hidden], body:has(#intro:not([hidden])) #recLamp { display: none; }
#recorder { border-top: 1px solid var(--line); padding-top: 10px; margin-top: 14px; }
#recorder { margin-top: 24px; margin-bottom: 24px; }
#recorder p { font: 11px/1.5 Silkscreen, monospace; color: var(--dim); }
#recorder .btn { min-height: 44px; }
#recorder .btn:disabled { opacity: 0.4; }
#recNew[hidden] { display: none; }
/* Gore gets a front door; the records become quiet navigation, with full touch targets. */
#mGore { display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 9px 12px; border-color: #804338; background: #281b19; color: #f1d6bf; }
#mGore b { font: 400 23px/1.1 Bangers, cursive; letter-spacing: 1px; }
#mGore i { margin: 0; color: #d5aa91; font: 400 9px/1.5 Silkscreen, monospace; letter-spacing: 0; }
/* Equal-sized entrances: solid mode colour fades into each mode's original poster. */
#mStart, #mGore { display: flex; flex-direction: column; align-items: center; justify-content: center;
box-sizing: border-box; height: 76px; min-height: 76px; padding: 10px 12px; gap: 4px;
background-size: cover; background-position: center; image-rendering: pixelated;
color: #f6e7dc; text-align: center; }
#mStart { background-image: linear-gradient(90deg, #a4231f 0%, #a4231f 18%,
rgba(164,35,31,.80) 30%, rgba(164,35,31,.12) 50%, rgba(0,0,0,.22) 100%), url('assets/mode_cup.png'); }
#mGore { border-color: #804338; background-color: #281b19;
background-image: linear-gradient(90deg, #281b19 0%, #281b19 18%,
rgba(40,27,25,.80) 30%, rgba(40,27,25,.12) 50%, rgba(0,0,0,.22) 100%), url('assets/mode_gore.png'); }
#mStart b, #mGore b { font: 400 26px/1.1 Bangers, cursive; letter-spacing: 1px;
text-shadow: 0 1px 3px rgba(0,0,0,.85); }
#mStart i, #mGore i { margin: 0; color: #f0d8c8; font: 400 9px/1.5 Silkscreen, monospace;
letter-spacing: .3px; text-shadow: 0 1px 3px #000; }
.splashLinks { display: flex; gap: 4px; margin-top: 5px; }
.splashLinks .btn.sec { flex: 1; width: auto; min-height: 44px; padding: 7px 2px;
background: transparent; border-color: transparent; color: #ccc4b5;
font-size: 16px; letter-spacing: 0; }
.splashLinks #mOther { flex: 1.5; }
.splashDoors { --door-height: 58px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 4px; }
.splashDoors .btn.sec { display: flex; align-items: center; justify-content: center;
min-height: var(--door-height); padding: 10px; text-align: center; font-size: 22px; letter-spacing: .3px; }
#mOther { border-color: #8eaa4d; color: #e6efbf; background-color: #263421;
background-image: linear-gradient(90deg, #263421 0%, #263421 18%,
rgba(38,52,33,.80) 30%, rgba(38,52,33,.20) 50%, rgba(0,0,0,.38) 100%), url('assets/mode_arcade.png'); }
#mVs { border-color: #607e6b; color: #edf1e8; background-color: #26322c;
background-image: linear-gradient(90deg, #26322c 0%, #26322c 18%,
rgba(38,50,44,.80) 30%, rgba(38,50,44,.20) 50%, rgba(0,0,0,.38) 100%), url('assets/mode_vs.png'); }
#mOther, #mVs { background-size: cover; background-position: center; image-rendering: pixelated;
text-shadow: 0 1px 2px #000, 0 0 5px #000; }
/* One occasional drop: Gore's lower edge -> friend button -> a slow run down its face.
50px bridges the 44px court link, 4px row gap and the friend's 2px border.
Both pieces share one CSS clock; no game-loop work, DOM additions or touch interception. */
#mVs { position: relative; --blood-gap: 50px; }
#mVs::before, #mVs::after { content: ''; position: absolute; pointer-events: none; opacity: 0; }
#mVs::before { z-index: 2; right: 14px; top: calc(-1 * var(--blood-gap)); width: 6px; height: 9px;
background: linear-gradient(90deg, #690b12 0%, #bb2027 55%, #e04436 75%, #8e1019 100%);
clip-path: polygon(40% 0, 60% 0, 100% 55%, 100% 78%, 70% 100%, 30% 100%, 0 78%, 0 55%); }
#mVs::after { z-index: 1; right: 16px; top: 0; width: 2px; height: calc(100% - 3px);
background: #9a1820; box-shadow: 1px 0 #510b10; transform-origin: top; }
#intro[data-stage="splash"] #splash:not(.busy) #mVs::before { animation: menuBloodDrop 18s 3s infinite; }
#intro[data-stage="splash"] #splash:not(.busy) #mVs::after { animation: menuBloodTrail 18s 3s infinite; }
@keyframes menuBloodDrop {
0% { opacity: 0; transform: translateY(0) scale(.25, .4); }
2% { opacity: 1; transform: translateY(1px) scale(.9, 1.1); }
5% { opacity: 1; transform: translateY(4px) scale(1, 1.3); animation-timing-function: ease-in; }
7% { opacity: 1; transform: translateY(var(--blood-gap)) scale(.8, 1.2); }
7.5% { opacity: 1; transform: translateY(var(--blood-gap)) scale(1.7, .55); }
9% { opacity: 1; transform: translateY(calc(var(--blood-gap) + 2px)) scale(1); animation-timing-function: linear; }
32% { opacity: 1; transform: translateY(calc(var(--blood-gap) + var(--door-height) - 10px)) scale(1, 1.15); }
35% { opacity: .8; transform: translateY(calc(var(--blood-gap) + var(--door-height) + 8px)) scale(.7, 1.3); }
36%, 100% { opacity: 0; transform: translateY(calc(var(--blood-gap) + var(--door-height) + 16px)); }
}
@keyframes menuBloodTrail {
0%, 7% { opacity: 0; transform: scaleY(0); }
7.5% { opacity: .85; transform: scaleY(.04); }
9% { opacity: .85; transform: scaleY(.06); animation-timing-function: linear; }
32% { opacity: .85; transform: scaleY(1); }
35% { opacity: .6; transform: scaleY(1); }
48%, 100% { opacity: 0; transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
#intro[data-stage="splash"] #splash:not(.busy) #mVs::before,
#intro[data-stage="splash"] #splash:not(.busy) #mVs::after { animation: none; }
}
.courtLink { display: block; width: 100%; min-height: 44px; padding: 6px 0; border: 0;
background: transparent; color: #d5aa91; font: 10px Silkscreen, monospace; cursor: pointer; }
.splashLinks { margin-top: 0; }
#splash #build { margin-top: 3px; }
@media (max-height: 680px) {
#intro .logoArt { width: min(220px, 55vw); }
#intro .slogan { font-size: 9px; margin: 4px 0 8px; }
#splash .mid { padding: 6px 0; }
#splash #plaque { margin-top: 8px; }
.splashDoors { --door-height: 50px; }
.splashDoors .btn.sec { font-size: 20px; }
#splash .foot { padding-top: 4px; }
#modeList button.mode { min-height: 128px; }
}
:is(#courtList, #lockedCourtList) button.court.lock { opacity: .7; }
#lockedCourtList button.court img { width: 56px; height: 40px; }
#lockedCourtList .meter { display: none; }
.tabs button { min-height: 44px; }
#pTours button { min-height: 44px; }
/* Colour choices span the screen: six swatches still have 44px targets on a 320px phone. */
#who2 { align-items: center; }
#pLook { height: 144px; }
#pTours { flex-direction: column; }
.playerColours { display: flex; flex-direction: column; gap: 7px; margin: 12px 0 20px; }
.playerColours .swrow { gap: 5px; }
.playerColours .swrow button { min-height: 44px; aspect-ratio: auto; height: 44px; }
.playerColours .swlab:not(:first-child) { margin-top: 8px; }
#pName { min-width: 0; }
#intro h3 span { font-size: 9px; }
button:focus-visible, summary:focus-visible { outline: 2px solid var(--acid); outline-offset: 3px; }
/* the splash's bottom band: no bar, no rule — the court art has to keep running under it */
.foot.bare { border: 0; background: none; }
.foot.bare .btn.big { margin-bottom: 10px; }
@@ -696,6 +738,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro.deal #splash #plaque { animation-delay: 0.02s; }
#intro.deal #splash #mStart { animation-delay: 0.12s; }
#intro.deal #splash #mGore { animation-delay: 0.19s; }
#intro.deal #splash #mCourt { animation-delay: 0.23s; }
#intro.deal #splash .foot.bare > .splashDoors { animation-delay: 0.26s; }
/* the navigation row is a CLASS, so it needs the same weight as the shorthand above
to override its delay — the id selectors around it get there on their own */
#intro.deal #splash .foot.bare > .splashLinks { animation-delay: 0.26s; }
@@ -711,7 +755,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#boot u { display: block; margin-top: 7px; text-align: center; text-decoration: none;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; }
#splash.busy #boot { display: block; }
#splash.busy .btn, #splash.busy .splashLinks, #splash.busy #build { display: none; }
#splash.busy .btn, #splash.busy .splashLinks, #splash.busy .splashDoors, #splash.busy .courtLink, #splash.busy #build { display: none; }
/* NOTHING DATA-DRIVEN DURING THE BOOT (2026-08-31, Oliver: "die oberfläche ist hier regelmäßig
in einem undefinierten zustand"). The plaque used to stand there as a skeleton, which is still
a plaque saying LEVEL 1 to a level-25 player for as long as the profile takes to land. The
@@ -744,6 +788,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
font-style: normal; color: var(--dim); letter-spacing: 1px; }
/* a GRID, not a wrapping flex row: `flex: 1 1 62px` gave the last row's tiles the whole width
to share, so a shelf of nine came out in three different sizes (Oliver) */
#stW > .none { grid-column: 1 / -1; }
#stW { display: grid; grid-template-columns: repeat(auto-fill, minmax(66px, 1fr)); gap: 6px; }
.wtile { padding: 8px 2px; border: 2px solid var(--line); background: var(--panel); }
.wtile img { width: 34px; height: 34px; object-fit: contain; display: block; margin: 0 auto 2px;
@@ -818,12 +863,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<canvas id="c" width="480" height="270"></canvas>
<div id="safe"></div>
<div id="hint">move: WASD/arrows &nbsp;·&nbsp; serve/swing: SPACE &nbsp;·&nbsp; aim: hold a direction while swinging &nbsp;·&nbsp; T: tuning panel</div>
<div id="quit" hidden><button id="qChip">X</button></div>
<div id="quit" hidden><button id="qChip" aria-label="Pause match">X</button></div>
<div id="qAsk" hidden>
<div class="qBox">
<p>LEAVE THE MATCH?</p>
<button class="btn big" id="qYes">BACK TO THE MENU</button>
<button class="btn sec" id="qNo">PLAY ON</button>
<button class="btn big" id="qNo">PLAY ON</button>
<button class="btn sec" id="qYes">LEAVE MATCH</button>
</div>
</div>
<div id="intro" data-stage="splash">
@@ -840,7 +885,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<div id="splash" class="busy">
<div id="hot"></div>
<div class="mid">
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
<div class="logoArt"><img class="logo" src="assets/logo-v2.png" alt="Splatter Tennis"></div>
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
<!-- THE PLAYER PLAQUE (the leagues): centred under the title on a translucent surface,
so the foot's blood pool stays visible. One tap opens the card (refreshSplash fills). -->
@@ -864,66 +909,41 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
already knows the answer to. It names the next cup fixture now and enters the draw;
Gore has its own secondary door; the other modes live behind OTHER GAMES. -->
<button class="btn big" id="mStart"><b id="msTitle">THE CUP</b><i id="msLine"></i></button>
<button class="btn sec" id="mGore"><b>SPLATTER GORE</b><i>NO CUP.<br>JUST CARNAGE.</i></button>
<div class="splashLinks">
<button class="btn sec" id="mGore"><b>SPLATTER GORE</b><i>NO CUP. JUST CARNAGE.</i></button>
<button class="courtLink" id="mCourt" aria-label="Choose court for Splatter Gore"></button>
<div class="splashDoors">
<button class="btn sec" id="mOther">OTHER GAMES</button>
<button class="btn sec" id="mStats">STATS</button>
<button class="btn sec" id="mRanks">BOARD</button>
<button class="btn sec" id="mVs">VS A FRIEND</button>
</div>
<div class="splashLinks">
<button class="btn sec" id="mRanks">WORLD BOARD</button>
</div>
<p id="build"></p>
</div>
</div>
<!-- tour tiles and court rows are both built from tables in main.js (config.TOURS / config.COURTS):
adding either is a table row, never markup -->
<!-- THE SETUP IS A WIZARD now (Oliver: one screen of posters + chips + tiles + meters was
"really overwhelming"). Three stages, tap advances: the mode posters, then the mode's own
second step when it has one (classic: difficulty, zombie tennis: arcade or the cup), then
who-and-where with SERVE UP. Every step comes up pre-selected from last time, so a
returning player is three taps from serving. -->
<!-- Additional solo modes; multiplayer has a separate splash entrance. -->
<div id="stmode">
<div class="hdr"><button class="chev" id="mBack">&lt;</button><h2>PICK A GAME</h2></div>
<div class="hdr"><button class="chev" aria-label="Back" id="mBack">&lt;</button><h2>OTHER GAMES</h2></div>
<div id="modeList"></div>
</div>
<div id="stsub">
<div class="hdr"><button class="chev" id="subBack">&lt;</button><h2 id="subTitle">DIFFICULTY</h2></div>
<div id="modeSub"></div>
</div>
<div id="setup">
<div class="hdr"><button class="chev" id="sBack">&lt;</button><h2>MATCH SETUP</h2></div>
<!-- ONE PLAYER BLOCK. It was four stacked sections — two big portrait tiles, then the live
player, then a kit row, then a skin row — which said the same thing twice (the tiles were
a second, FRONT-FACING drawing of a man who does not react to any of the colours) and ran
the court off the bottom of the phone. The tiles are gone: the live player IS the picture,
so who-you-are shrinks to two words, and every control that changes him stands beside him
where you can watch it land. Kit and skin rows are built in main.js from the tables. -->
<h3 id="s01">01 · PLAYER</h3>
<div id="who">
<canvas id="look" width="128" height="224"></canvas>
<div id="whoCtl">
<div id="tours"></div>
<u class="swlab">KIT<i id="kitName"></i></u>
<div id="kitRow" class="swrow"></div>
<u class="swlab">SKIN<i id="skinName"></i></u>
<div id="skinRow" class="swrow"></div>
</div>
<div class="hdr"><button class="chev" aria-label="Back" id="sBack">&lt;</button><h2 id="sTitle">MATCH SETUP</h2></div>
<button id="sEdit" class="playerLink">
<span><b id="sPlayerName"></b><i id="sPlayerKit"></i></span><u>EDIT PLAYER</u>
</button>
<div id="sDifficulty" hidden>
<h3>DIFFICULTY</h3>
<div id="difficultyList" class="tabs"></div>
</div>
<!-- THE LEAGUE PANEL (the leagues): on the league modes this stands where the player block
does elsewhere — the identity already lives on the card, so the setup shows the thing
that matters HERE instead: your league, writ large, and who of its band you have
already felled (all-time, from the stats' own per-foe tallies). Built by
main.buildLeaguePanel, toggled by syncSetup. -->
<div id="lgPanel">
<div id="lgHead">
<img id="lgBadge" alt="">
<div><b id="lgName"></b><i id="lgSub"></i></div>
</div>
<div id="lgFoes"></div>
</div>
<h3 id="s02">02 · COURT</h3>
<div id="sOpponent" class="opponentRow" hidden><img alt=""><span><i>OPPONENT</i><b></b></span></div>
<p id="sClimb" class="setupNote" hidden></p>
<h3>COURT<span>PACE · BOUNCE</span></h3>
<div id="courtList"></div>
<div class="foot">
<button class="btn big" id="cStart">SERVE UP</button>
</div>
<details id="lockedCourts" class="disclosure" open>
<summary id="lockedCourtLabel">LOCKED COURTS</summary>
<div id="lockedCourtList"></div>
</details>
<div class="foot"><button class="btn big" id="cStart">SERVE UP</button></div>
</div>
<!-- THE IDENTITY (the leagues, phase 1): the first boot asks who walks on — a name (prefilled
with a zombie name, the die rolls another), the tour and the colours. Everything here
@@ -931,7 +951,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
keys), so the two screens cannot disagree about who you are. Rebuilt on every visit by
main.buildPlayer, so the .on states track the setup screen's. -->
<div id="player">
<div class="hdr"><button class="chev" id="pBack">&lt;</button><h2>YOUR PLAYER</h2></div>
<div class="hdr"><button class="chev" aria-label="Back" id="pBack">&lt;</button><h2>YOUR PLAYER</h2></div>
<h3>NAME<span>YOURS ON EVERY BOARD</span></h3>
<div id="nameRow">
<input id="pName" class="tin" maxlength="12" autocomplete="off" spellcheck="false">
@@ -942,14 +962,16 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<canvas id="pLook" width="128" height="224"></canvas>
<div id="pCtl">
<div id="pTours"></div>
<u class="swlab">KIT<i id="pKitName"></i></u>
<div id="pKitRow" class="swrow"></div>
<u class="swlab">SKIN<i id="pSkinName"></i></u>
<div id="pSkinRow" class="swrow"></div>
</div>
</div>
<div class="playerColours">
<u class="swlab">KIT<i id="pKitName"></i></u>
<div id="pKitRow" class="swrow"></div>
<u class="swlab">SKIN<i id="pSkinName"></i></u>
<div id="pSkinRow" class="swrow"></div>
</div>
<div class="foot">
<p class="cent">THE CLUB WILL LEARN YOUR NAME.</p>
<p class="cent" id="pNote">THE CLUB WILL LEARN YOUR NAME.</p>
<button class="btn big" id="pGo">ENTER THE CLUB</button>
</div>
</div>
@@ -975,7 +997,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
main.buildCard from the profile + stats; the honours row shows every badge slot, earned
bright and unearned dark. -->
<div id="card">
<div class="hdr"><button class="chev" id="cdBack">&lt;</button><h2 id="cdName">YOUR PLAYER</h2></div>
<div class="hdr"><button class="chev" aria-label="Back" id="cdBack">&lt;</button><h2 id="cdName">YOUR PLAYER</h2></div>
<div id="cdTop">
<canvas id="cdLook" width="128" height="224"></canvas>
<div id="cdMeta">
@@ -989,10 +1011,19 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
<h3>HONOURS<span>WIN THE CUP TO CLIMB</span></h3>
<div id="cdBadges"></div>
<h3>THE LEDGER<span>ALL TIME</span></h3>
<h3>CAREER</h3>
<div class="figs" id="cdFigs1"></div>
<div style="height:8px"></div>
<div class="figs" id="cdFigs2"></div>
<button class="btn sec" id="cdStats">STATS<span aria-hidden="true">&gt;</span></button>
<details class="disclosure" id="cdLeagueDetails" open>
<summary>YOUR LEAGUE</summary>
<div id="lgPanel">
<div id="lgHead">
<img id="lgBadge" alt="">
<div><b id="lgName"></b><i id="lgSub"></i></div>
</div>
<div id="lgFoes"></div>
</div>
</details>
<div class="foot bare">
<button class="btn sec" id="cdEdit">EDIT PLAYER<i>NAME · TOUR · COLOURS</i></button>
</div>
@@ -1000,7 +1031,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<!-- THE TRAINING COURT's opponent picker: the whole ladder face-up, tap a body to drill it.
Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. -->
<div id="train">
<div class="hdr"><button class="chev" id="trBack">&lt;</button><h2>TRAINING</h2></div>
<div class="hdr"><button class="chev" aria-label="Back" id="trBack">&lt;</button><h2>TRAINING</h2></div>
<h3>CHOOSE AN OPPONENT</h3>
<div id="trainList"></div>
</div>
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
@@ -1008,7 +1040,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
a socket is open, so the room code and its SHARE button stand where the buttons were and
the game stays parked behind this overlay until the room fills (main.js, THE LOBBY). -->
<div id="vs">
<div class="hdr"><button class="chev" id="vsBack">&lt;</button><h2>VS A FRIEND</h2></div>
<div class="hdr"><button class="chev" aria-label="Back" id="vsBack">&lt;</button><h2>VS A FRIEND</h2></div>
<div id="vsPick">
<h3>YOU</h3>
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
@@ -1036,30 +1068,31 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
<!-- BODY COUNT. Every row in here is built by stats.js from the run records in localStorage. -->
<div id="stats">
<div class="hdr"><button class="chev" id="stBack">&lt;</button><h2>BODY COUNT</h2></div>
<div class="hdr"><button class="chev" aria-label="Back" id="stBack">&lt;</button><h2>STATS</h2></div>
<!-- NO TABS (2026-08-31). This screen carried three MODE tabs times three PERIOD tabs, and
the same three boxes meant something different in every one of the nine states. It is
your career now, in four numbers that always mean the same thing, plus the two things
the screen is actually good at: the bodies you have collected, and the rack you fired. -->
<div class="figs pair" id="stFigs"></div>
<section id="recorder">
<h3>PLAYTEST RECORDING</h3>
<h3>BY OPPONENT<span>ALL TIME</span></h3>
<div id="stZ"></div>
<h3>BY WEAPON</h3>
<div id="stW"></div>
<details id="recorder" class="disclosure" open>
<summary id="recLabel">PLAYTEST RECORDING</summary>
<p>Record league, Climb and training points to investigate difficulty. Export here after playing.</p>
<p id="recStatus" role="status"></p>
<button class="btn sec" id="recToggle">RECORD PLAYTEST</button>
<button class="btn sec" id="recExport" disabled>EXPORT RECORDING</button>
<button class="btn sec" id="recNew" hidden>REPLACE WITH NEW RECORDING</button>
</section>
<h3>BY OPPONENT<span>ALL TIME</span></h3>
<div id="stZ"></div>
<h3>BY WEAPON</h3>
<div id="stW"></div>
</details>
</div>
<!-- THREE BOARDS, one per game mode — the three do not share a currency, so they cannot share
a ranking. Same tab strip as the stats screen's, and stats.drawRanks rewrites the column
heading with the tab, because the number means something different on each one. -->
<div id="ranks">
<div class="hdr"><button class="chev" id="rkBack">&lt;</button><h2>WORLD BOARD</h2></div>
<div class="hdr"><button class="chev" aria-label="Back" id="rkBack">&lt;</button><h2>WORLD BOARD</h2></div>
<!-- TWO boards, not three (2026-08-31): classic tennis is out of competition by design now
— no league, no record, no stakes — so ranking it contradicted the room it lives in. -->
<div class="tabs" id="rkModes">
@@ -1074,7 +1107,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
at it, which is the whole point: it replaced a triple-tap on the in-play scoreboard that
players kept tripping by accident. -->
<div id="secret">
<div class="hdr"><button class="chev" id="scBack">&lt;</button><h2>BACK ROOM</h2></div>
<div class="hdr"><button class="chev" aria-label="Back" id="scBack">&lt;</button><h2>BACK ROOM</h2></div>
<div class="foot bare">
<button class="btn big" id="scWeapons">WEAPON TEST</button>
<button class="btn big" id="scBosses">BOSS TEST</button>
@@ -1086,7 +1119,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<!-- one tile per chapter boss, built from config.bossArt in main.js: adding a design to the
roster puts it on this screen and never touches this markup -->
<div id="bosses">
<div class="hdr"><button class="chev" id="bsBack">&lt;</button><h2>BOSS TEST</h2></div>
<div class="hdr"><button class="chev" aria-label="Back" id="bsBack">&lt;</button><h2>BOSS TEST</h2></div>
<h3>PICK A CHAPTER<span>FULL ARSENAL</span></h3>
<div id="bossList"></div>
</div>
@@ -1107,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=352"></script>
<script type="module" src="src/main.js?v=353"></script>
</body>
</html>