diff --git a/index.html b/index.html
index 9ec6287..00dd47d 100644
--- a/index.html
+++ b/index.html
@@ -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; }
/* 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 takes the announcement face a size under the screen title,
- so the hierarchy reads screen > section > poster; the rule under it closes the header and the
- inset band down the left says where the section ends. */
- #modeList .mgroup { margin: 0 0 22px; padding-left: 10px; border-left: 2px solid var(--line); }
+ 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 12px; }
+ #modeList .mgroup + .mgroup { margin-top: 26px; }
/* 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 10px; padding-bottom: 7px; text-align: left;
- border-bottom: 1px solid var(--line); }
- #modeList .mgroup b { display: block; font: 400 21px Bangers, cursive; letter-spacing: 1.5px;
- color: var(--acid); text-shadow: none; line-height: 1.1; }
- #modeList .mgroup i { display: block; margin: 2px 0 0; font: 400 9px Silkscreen, monospace;
+ #modeList .mgroup > header { margin: 0 0 11px; text-align: left; }
+ #modeList .mgroup b { display: block; font: 700 14px Silkscreen, monospace; letter-spacing: 2.5px;
+ color: var(--acid); text-shadow: none; line-height: 1.2; }
+ #modeList .mgroup i { display: block; margin: 5px 0 0; font: 400 9px Silkscreen, monospace;
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 */
- #modeList .mgroup:first-child { border-left-color: var(--red); }
+ /* the canonical room is named in the primary's colour — the one that moves your career */
#modeList .mgroup:first-child b { color: #f0a08e; }
#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
@@ -511,6 +511,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
padding-bottom: max(10px, env(safe-area-inset-bottom));
border-top: 2px solid var(--line); background: var(--solid); }
.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 */
.foot.bare { border: 0; background: none; }
.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; }
#splash.busy #boot { display: block; }
#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.
- The profile mirror may still be in flight, and half-true numbers must neither show nor
- navigate (main.js holds .busy until the pull settles or its 4 s cap fires). */
- #splash.busy #plaque { pointer-events: none; }
- #splash.busy #plaque em { visibility: hidden; }
- #splash.busy #plBadge { opacity: 0.14; filter: grayscale(1); }
- #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; }
+ /* 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
+ title and the court stay — they are the background and they are never undefined. */
+ #splash.busy #plaque { visibility: hidden; } /* hidden, but it KEEPS ITS SPACE: display:none
+ let the title slide down the screen and jump back up the moment the profile landed */
#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; }
.tabs { display: flex; gap: 6px; margin: 12px 0 14px; }
diff --git a/src/render.js b/src/render.js
index a712ba3..a7a619c 100644
--- a/src/render.js
+++ b/src/render.js
@@ -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
// 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).
-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_RULE = 'rgba(120,150,130,0.22)';
// 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 push = r => { rows.push(r); return r; };
if (m.foe) {
@@ -2377,7 +2379,7 @@ function modalRows(m, inner) {
push({ kind: 'rule', lead, sub, hot: r.hot, pips: r.pips,
h: lead.length * 12 + sub.length * 12 + (r.pips ? 12 : 0) + 2 });
}
- } else {
+ } else if (!skipBody) {
const lines = [];
for (const l of m.body || []) {
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
// has to know whether it fits beside the art before either is placed.
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 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 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
@@ -2401,10 +2407,18 @@ function modalLayout(m) {
ctx.font = B(22);
const tw = ctx.measureText(m.title).width;
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 rows = modalRows(m, boxW - pad * 2);
- let h = pad + headH + MODAL_GAP + MODAL_BTN + pad;
+ const rows = modalRows(m, boxW - pad * 2, beside);
+ let h = pad + headH + MODAL_GAP + MODAL_FOOT + MODAL_BTN + pad;
for (const r of rows) h += r.h + MODAL_GAP;
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) };
@@ -2485,11 +2499,10 @@ function drawModal(m) {
// lie: dismissModal swallows the gesture until then.
if (t >= 1) {
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.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();
- 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';
}
@@ -3052,7 +3065,7 @@ export function render() {
const xd = runDelta(), pr = progress();
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 lineLines = wrapText(line, F(9), inner);
// 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;
const fxLines = ready ? wrapText(promo ? 'THE CLUB WANTS A WORD'
: G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : [];
- let h = pad + headLines.length * 24 + lineLines.length * 13 + 4;
- if (xd.xp > 0) h += MODAL_GAP + 26;
+ let h = pad + headLines.length * 24 + lineLines.length * 13 + 6;
+ if (xd.xp > 0) h += MODAL_GAP + 32;
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;
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);
@@ -3079,7 +3092,7 @@ export function render() {
y += headLines.length * 24;
ctx.font = F(9);
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 = () => {
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
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
: ' · LEVEL ' + pr.level),
inX, y + 7, xd.to > xd.from ? UI.pick : UI.cream);
- const bw = inner, by = y + 18;
- ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(inX, by, bw, 4);
- // the fill is a gradient (Oliver's call), green into gold — same ramp as the splash strip
- const gr = ctx.createLinearGradient(inX, 0, inX + bw, 0);
+ const bw = inner, by = y + 20, bh = 7;
+ ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(inX, by, bw, bh);
+ // THE RAMP RIDES THE FILL, not the track (Oliver: "auch den farbigen verlauf anzeigen").
+ // 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');
- ctx.fillStyle = gr; ctx.fillRect(inX, by, Math.round(bw * pr.frac), 4);
- y += 26;
+ ctx.fillStyle = gr; ctx.fillRect(inX, by, fw, bh);
+ y += 32;
}
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
rule();
@@ -3119,14 +3135,14 @@ export function render() {
if (ready) {
rule();
ctx.font = F(7, 0);
- shadowText('NEXT', inX, y + 5, UI.dim);
+ shadowText('NEXT', inX, y + 6, UI.dim);
ctx.font = F(9, 700);
shadowText(promo ? 'THE PROMOTION' : G.net ? 'BACK TO THE CLUBHOUSE' : fx.title,
- inX + 34, y + 5, UI.warm);
- y += 14;
+ inX + 34, y + 6, UI.warm);
+ y += 16;
ctx.font = F(8, 0);
- fxLines.forEach((l, i) => shadowText(l, inX, y + 6 + i * 12, UI.dim));
- y += fxLines.length * 12 + 6;
+ fxLines.forEach((l, i) => shadowText(l, inX, y + 8 + i * 12, UI.dim));
+ y += fxLines.length * 12 + MODAL_FOOT;
// 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
// NETPLAY ends on one door only: the other phone's socket is still being torn down, and