The overlays get their measures: narrower boxes, full-width buttons, air

Oliver's second pass over the new box language. The modal is narrower and the
button spans it; every row got real spacing and the footer its own margin, so
the end screens stop reading as cramped. The xp bar is taller and its ramp now
rides the FILL — spanning the gradient across the whole track meant an early
level only ever showed one flat green inch of it. An illustrated announcement
puts its two lines beside the picture, under the headline, instead of in a
boxed row below its own icon.

The poster wall's section headings leave the announcement face to the posters
(it read as a second card headline), drop the left band and the rule, and let
air do the grouping. On the splash, OTHER GAMES gets its gap to the stats row,
and the boot shows nothing but the court, the title and the loading bar — the
plaque skeleton was still a plaque telling a level-25 player he was level 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-31 12:15:23 +02:00
1 parent e2cb13e46d
commit e5a0d1a5f9
2 files changed
+61 -47

No files matched your search

+19 -21
View File
@@ -457,21 +457,21 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
text-shadow: 0 1px 0 #000, 1px 0 0 #000, -1px 0 0 #000, 0 -1px 0 #000, 0 0 7px #000; } 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 /* 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 room where nothing counts — so each is a real section with its posters INSIDE it, not a label
floating over a stack. The heading takes the announcement face a size under the screen title, floating over a stack. The heading is deliberately NOT the announcement face (Oliver: it read
so the hierarchy reads screen > section > poster; the rule under it closes the header and the as a second card headline and the two competed); Silkscreen, big and letter-spaced, keeps it
inset band down the left says where the section ends. */ a label of a room. No rule, no left band either — the air above each heading does the
#modeList .mgroup { margin: 0 0 22px; padding-left: 10px; border-left: 2px solid var(--line); } grouping, which is what air is for. */
#modeList .mgroup { margin: 0 0 12px; }
#modeList .mgroup + .mgroup { margin-top: 26px; }
/* text-align rides down from the card, which centres everything on these screens — the section /* 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' header is a header, and headers on this screen are left-aligned (the .hdr title, the posters'
own captions) */ own captions) */
#modeList .mgroup > header { margin: 0 0 10px; padding-bottom: 7px; text-align: left; #modeList .mgroup > header { margin: 0 0 11px; text-align: left; }
border-bottom: 1px solid var(--line); } #modeList .mgroup b { display: block; font: 700 14px Silkscreen, monospace; letter-spacing: 2.5px;
#modeList .mgroup b { display: block; font: 400 21px Bangers, cursive; letter-spacing: 1.5px; color: var(--acid); text-shadow: none; line-height: 1.2; }
color: var(--acid); text-shadow: none; line-height: 1.1; } #modeList .mgroup i { display: block; margin: 5px 0 0; font: 400 9px Silkscreen, monospace;
#modeList .mgroup i { display: block; margin: 2px 0 0; font: 400 9px Silkscreen, monospace;
font-style: normal; color: var(--dim); letter-spacing: 0.5px; text-shadow: none; } font-style: normal; color: var(--dim); letter-spacing: 0.5px; text-shadow: none; }
/* the canonical room wears the primary's colour — the one section that moves your career */ /* the canonical room is named in the primary's colour — the one that moves your career */
#modeList .mgroup:first-child { border-left-color: var(--red); }
#modeList .mgroup:first-child b { color: #f0a08e; } #modeList .mgroup:first-child b { color: #f0a08e; }
#modeList .mgroup button.mode:last-child { margin-bottom: 0; } #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 /* THE SECOND STEP (config.MENU) has its own SCREEN in the wizard: sub-mode cards are
@@ -511,6 +511,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
padding-bottom: max(10px, env(safe-area-inset-bottom)); padding-bottom: max(10px, env(safe-area-inset-bottom));
border-top: 2px solid var(--line); background: var(--solid); } border-top: 2px solid var(--line); background: var(--solid); }
.foot .btn { margin-bottom: 0; } .foot .btn { margin-bottom: 0; }
/* ...except the splash's own secondary door: OTHER GAMES sat flush on the STATS/RANKINGS row */
.foot.bare #mOther { margin-bottom: 12px; }
/* the splash's bottom band: no bar, no rule — the court art has to keep running under it */ /* 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 { border: 0; background: none; }
.foot.bare .btn.big { margin-bottom: 10px; } .foot.bare .btn.big { margin-bottom: 10px; }
@@ -524,16 +526,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; } font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; }
#splash.busy #boot { display: block; } #splash.busy #boot { display: block; }
#splash.busy .btn, #splash.busy .two, #splash.busy #build { display: none; } #splash.busy .btn, #splash.busy .two, #splash.busy #build { display: none; }
/* ...and the PLAQUE boots as a SKELETON: no tap, ghost crest, blank pills where the words go. /* NOTHING DATA-DRIVEN DURING THE BOOT (2026-08-31, Oliver: "die oberfläche ist hier regelmäßig
The profile mirror may still be in flight, and half-true numbers must neither show nor in einem undefinierten zustand"). The plaque used to stand there as a skeleton, which is still
navigate (main.js holds .busy until the pull settles or its 4 s cap fires). */ a plaque saying LEVEL 1 to a level-25 player for as long as the profile takes to land. The
#splash.busy #plaque { pointer-events: none; } title and the court stay — they are the background and they are never undefined. */
#splash.busy #plaque em { visibility: hidden; } #splash.busy #plaque { visibility: hidden; } /* hidden, but it KEEPS ITS SPACE: display:none
#splash.busy #plBadge { opacity: 0.14; filter: grayscale(1); } let the title slide down the screen and jump back up the moment the profile landed */
#splash.busy #plName, #splash.busy #plLv, #splash.busy #plGoal {
color: transparent; text-shadow: none; background: rgba(255,255,255,0.08);
border-radius: 4px; }
#splash.busy #plRow s i { width: 0% !important; }
#sSum, #brSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px; #sSum, #brSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; } font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.tabs { display: flex; gap: 6px; margin: 12px 0 14px; } .tabs { display: flex; gap: 6px; margin: 12px 0 14px; }
+42 -26
View File
@@ -2357,12 +2357,14 @@ const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2;
// sent and the suite still reads. This is a layout, not new state. Any tap still dismisses (the // sent and the suite still reads. This is a layout, not new state. Any tap still dismisses (the
// button is the affordance, not a hit target — a modal you can only leave through one 26px strip // button is the affordance, not a hit target — a modal you can only leave through one 26px strip
// would be a worse wall than the one we had). // would be a worse wall than the one we had).
const MODAL_PAD = 12, MODAL_GAP = 9, MODAL_BTN = 26; // Oliver's measures (2026-08-31, second pass): narrower box, full-width button, and real air —
// between the rows, and especially above the footer. The old 9px gap read as "gedrungen".
const MODAL_PAD = 14, MODAL_GAP = 13, MODAL_BTN = 30, MODAL_FOOT = 8;
const MODAL_FILL = 'rgba(8,22,16,0.93)', MODAL_EDGE = 'rgba(120,150,130,0.45)'; const MODAL_FILL = 'rgba(8,22,16,0.93)', MODAL_EDGE = 'rgba(120,150,130,0.45)';
const MODAL_RULE = 'rgba(120,150,130,0.22)'; const MODAL_RULE = 'rgba(120,150,130,0.22)';
// One row of the stack, measured. `inner` is the text width inside the box. // One row of the stack, measured. `inner` is the text width inside the box.
function modalRows(m, inner) { function modalRows(m, inner, skipBody) {
const rows = []; const rows = [];
const push = r => { rows.push(r); return r; }; const push = r => { rows.push(r); return r; };
if (m.foe) { if (m.foe) {
@@ -2377,7 +2379,7 @@ function modalRows(m, inner) {
push({ kind: 'rule', lead, sub, hot: r.hot, pips: r.pips, push({ kind: 'rule', lead, sub, hot: r.hot, pips: r.pips,
h: lead.length * 12 + sub.length * 12 + (r.pips ? 12 : 0) + 2 }); h: lead.length * 12 + sub.length * 12 + (r.pips ? 12 : 0) + 2 });
} }
} else { } else if (!skipBody) {
const lines = []; const lines = [];
for (const l of m.body || []) { for (const l of m.body || []) {
if (l === '') { lines.push(''); continue; } // the weapon test's deliberate blank if (l === '') { lines.push(''); continue; } // the weapon test's deliberate blank
@@ -2391,9 +2393,13 @@ function modalRows(m, inner) {
// Everything measured before anything is drawn: the box has to know its own height, and the title // Everything measured before anything is drawn: the box has to know its own height, and the title
// has to know whether it fits beside the art before either is placed. // has to know whether it fits beside the art before either is placed.
function modalLayout(m) { function modalLayout(m) {
const boxW = Math.min(W - 20, 268), pad = MODAL_PAD; const boxW = Math.min(W - 44, 244), pad = MODAL_PAD;
const art = m.foe ? (m.foe.head && SPR[m.foe.head]) : (m.icon && SPR[m.icon]); const art = m.foe ? (m.foe.head && SPR[m.foe.head]) : (m.icon && SPR[m.icon]);
const artH = art ? Math.round(Math.min(m.iconH || 46, H * 0.2)) : 0; // an illustration that shares its row with two lines of copy gives some height back, so the text
// column stays wide enough to hold a sentence without orphaning its last word
const bodyLines = (m.body || []).filter(l => l);
const pairs = !!art && !m.foe && !m.rules && bodyLines.length && bodyLines.length <= 3;
const artH = art ? Math.round(Math.min(m.iconH || 46, pairs ? 42 : 46, H * 0.2)) : 0;
const artW = art ? Math.round(artH * art.width / art.height) : 0; const artW = art ? Math.round(artH * art.width / art.height) : 0;
const headW = boxW - pad * 2 - (art ? artW + 10 : 0); const headW = boxW - pad * 2 - (art ? artW + 10 : 0);
// the title SHRINKS to its lane rather than running off the box — boss names and THE TRAINING // the title SHRINKS to its lane rather than running off the box — boss names and THE TRAINING
@@ -2401,10 +2407,18 @@ function modalLayout(m) {
ctx.font = B(22); ctx.font = B(22);
const tw = ctx.measureText(m.title).width; const tw = ctx.measureText(m.title).width;
const tSize = tw > headW ? Math.max(13, Math.floor(22 * headW / tw)) : 22; const tSize = tw > headW ? Math.max(13, Math.floor(22 * headW / tw)) : 22;
const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW) : []; // THE STRAP is whatever belongs BESIDE the art rather than under a rule: the body's slogan on a
// walk-on, and — Oliver — the two lines of an illustrated announcement, which had no business
// being a separate boxed row under its own icon ("bei wounds treated kann der text direkt unter
// die überschrift"). Only when it fits: three lines is a strap, five is a paragraph.
const wrapped = pairs ? bodyLines.flatMap(l => wrapText(l, F(8, 0), headW)) : [];
// ...and only when it actually FITS there: a line that wraps in the narrow column belongs under
// the head as an ordinary row, not as a ragged column beside a picture
const beside = pairs && wrapped.length <= bodyLines.length + 1;
const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW) : beside ? wrapped : [];
const headH = Math.max(artH, 22 + strap.length * 12); const headH = Math.max(artH, 22 + strap.length * 12);
const rows = modalRows(m, boxW - pad * 2); const rows = modalRows(m, boxW - pad * 2, beside);
let h = pad + headH + MODAL_GAP + MODAL_BTN + pad; let h = pad + headH + MODAL_GAP + MODAL_FOOT + MODAL_BTN + pad;
for (const r of rows) h += r.h + MODAL_GAP; for (const r of rows) h += r.h + MODAL_GAP;
return { boxW, pad, art, artW, artH, tSize, strap, headH, rows, h, return { boxW, pad, art, artW, artH, tSize, strap, headH, rows, h,
x: Math.round(W / 2 - boxW / 2), y: Math.round(H / 2 - h / 2) }; x: Math.round(W / 2 - boxW / 2), y: Math.round(H / 2 - h / 2) };
@@ -2485,11 +2499,10 @@ function drawModal(m) {
// lie: dismissModal swallows the gesture until then. // lie: dismissModal swallows the gesture until then.
if (t >= 1) { if (t >= 1) {
const label = m.btn || (m.foe ? 'WALK ON' : m.rules ? 'GOT IT' : 'PLAY ON'); const label = m.btn || (m.foe ? 'WALK ON' : m.rules ? 'GOT IT' : 'PLAY ON');
const bw = Math.min(L.boxW - pad * 2, 168), bx = Math.round(x + L.boxW / 2 - bw / 2);
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1; ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x + 6, y - Math.round(MODAL_GAP / 2) + 0.5); ctx.beginPath(); ctx.moveTo(x + 6, y - Math.round(MODAL_GAP / 2) + 0.5);
ctx.lineTo(x + L.boxW - 6, y - Math.round(MODAL_GAP / 2) + 0.5); ctx.stroke(); ctx.lineTo(x + L.boxW - 6, y - Math.round(MODAL_GAP / 2) + 0.5); ctx.stroke();
drawButton(bx, y, bw, MODAL_BTN, label, col, true); drawButton(inX, y + MODAL_FOOT, L.boxW - pad * 2, MODAL_BTN, label, col, true);
} }
ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
} }
@@ -3052,7 +3065,7 @@ export function render() {
const xd = runDelta(), pr = progress(); const xd = runDelta(), pr = progress();
const ready = dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART; const ready = dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART;
const boxW = Math.min(W - 20, 288), pad = MODAL_PAD, inner = boxW - pad * 2; const boxW = Math.min(W - 32, 276), pad = MODAL_PAD, inner = boxW - pad * 2;
const headLines = wrapText(title, B(22), inner); const headLines = wrapText(title, B(22), inner);
const lineLines = wrapText(line, F(9), inner); const lineLines = wrapText(line, F(9), inner);
// the cup keeps ITS OWN mark (the round reached, in the headline pair above) — the board's // the cup keeps ITS OWN mark (the round reached, in the headline pair above) — the board's
@@ -3065,10 +3078,10 @@ export function render() {
: null; : null;
const fxLines = ready ? wrapText(promo ? 'THE CLUB WANTS A WORD' const fxLines = ready ? wrapText(promo ? 'THE CLUB WANTS A WORD'
: G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : []; : G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : [];
let h = pad + headLines.length * 24 + lineLines.length * 13 + 4; let h = pad + headLines.length * 24 + lineLines.length * 13 + 6;
if (xd.xp > 0) h += MODAL_GAP + 26; if (xd.xp > 0) h += MODAL_GAP + 32;
if (G.newBest || mark) h += MODAL_GAP + (G.newBest ? 20 : 13); if (G.newBest || mark) h += MODAL_GAP + (G.newBest ? 20 : 13);
if (ready) h += MODAL_GAP + 14 + fxLines.length * 12 + 6 + MODAL_BTN; if (ready) h += MODAL_GAP + 16 + fxLines.length * 12 + MODAL_FOOT + MODAL_BTN;
h += pad; h += pad;
const x = Math.round(W / 2 - boxW / 2), y0 = Math.round(H / 2 - h / 2), inX = x + pad; const x = Math.round(W / 2 - boxW / 2), y0 = Math.round(H / 2 - h / 2), inX = x + pad;
panel(x, y0, boxW, h, 4, MODAL_FILL, MODAL_EDGE); panel(x, y0, boxW, h, 4, MODAL_FILL, MODAL_EDGE);
@@ -3079,7 +3092,7 @@ export function render() {
y += headLines.length * 24; y += headLines.length * 24;
ctx.font = F(9); ctx.font = F(9);
lineLines.forEach((l, i) => shadowText(l, inX, y + 7 + i * 13, UI.cream)); lineLines.forEach((l, i) => shadowText(l, inX, y + 7 + i * 13, UI.cream));
y += lineLines.length * 13 + 4; y += lineLines.length * 13 + 6;
const rule = () => { const rule = () => {
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1; ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x + 6, y + Math.round(MODAL_GAP / 2) + 0.5); ctx.beginPath(); ctx.moveTo(x + 6, y + Math.round(MODAL_GAP / 2) + 0.5);
@@ -3094,13 +3107,16 @@ export function render() {
shadowText('+' + xd.xp + ' XP' + (xd.to > xd.from ? ' · LEVEL ' + xd.from + ' → ' + xd.to shadowText('+' + xd.xp + ' XP' + (xd.to > xd.from ? ' · LEVEL ' + xd.from + ' → ' + xd.to
: ' · LEVEL ' + pr.level), : ' · LEVEL ' + pr.level),
inX, y + 7, xd.to > xd.from ? UI.pick : UI.cream); inX, y + 7, xd.to > xd.from ? UI.pick : UI.cream);
const bw = inner, by = y + 18; const bw = inner, by = y + 20, bh = 7;
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(inX, by, bw, 4); ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(inX, by, bw, bh);
// the fill is a gradient (Oliver's call), green into gold — same ramp as the splash strip // THE RAMP RIDES THE FILL, not the track (Oliver: "auch den farbigen verlauf anzeigen").
const gr = ctx.createLinearGradient(inX, 0, inX + bw, 0); // Spanning it across the whole bar meant an early level showed one flat green inch of a
// gradient nobody ever saw — green into gold runs from empty to wherever you are now.
const fw = Math.max(2, Math.round(bw * pr.frac));
const gr = ctx.createLinearGradient(inX, 0, inX + fw, 0);
gr.addColorStop(0, '#3f9560'); gr.addColorStop(0.55, UI.pick); gr.addColorStop(1, '#e8b23a'); gr.addColorStop(0, '#3f9560'); gr.addColorStop(0.55, UI.pick); gr.addColorStop(1, '#e8b23a');
ctx.fillStyle = gr; ctx.fillRect(inX, by, Math.round(bw * pr.frac), 4); ctx.fillStyle = gr; ctx.fillRect(inX, by, fw, bh);
y += 26; y += 32;
} }
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
rule(); rule();
@@ -3119,14 +3135,14 @@ export function render() {
if (ready) { if (ready) {
rule(); rule();
ctx.font = F(7, 0); ctx.font = F(7, 0);
shadowText('NEXT', inX, y + 5, UI.dim); shadowText('NEXT', inX, y + 6, UI.dim);
ctx.font = F(9, 700); ctx.font = F(9, 700);
shadowText(promo ? 'THE PROMOTION' : G.net ? 'BACK TO THE CLUBHOUSE' : fx.title, shadowText(promo ? 'THE PROMOTION' : G.net ? 'BACK TO THE CLUBHOUSE' : fx.title,
inX + 34, y + 5, UI.warm); inX + 34, y + 6, UI.warm);
y += 14; y += 16;
ctx.font = F(8, 0); ctx.font = F(8, 0);
fxLines.forEach((l, i) => shadowText(l, inX, y + 6 + i * 12, UI.dim)); fxLines.forEach((l, i) => shadowText(l, inX, y + 8 + i * 12, UI.dim));
y += fxLines.length * 12 + 6; y += fxLines.length * 12 + MODAL_FOOT;
// one button when the ceremony owns the tap, two when there is a real choice: the fixture // one button when the ceremony owns the tap, two when there is a real choice: the fixture
// (the canonical path) and the way back to what you were just doing // (the canonical path) and the way back to what you were just doing
// NETPLAY ends on one door only: the other phone's socket is still being torn down, and // NETPLAY ends on one door only: the other phone's socket is still being torn down, and