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