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

+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
// 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