diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 57e4a890..e65810a0 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2965,11 +2965,16 @@ Object.assign(CodemanApp.prototype, { const fit = rt.balloonFit; rt.balloonKey = `${h}|${fit?.serial ?? '-'}|${b.className}|${b.textContent}`; text.style.removeProperty('--story-lines'); - const limit = fit ? fit.limit + h - fit.stageH : h - 12; + // A panel ignores a growth of 2 px or less (_storyFitCaption): the balloon may use that much of the + // gap it keeps, or such a panel would show one line fewer than its fit made room for. + const limit = (fit ? fit.limit + h - fit.stageH : h - 12) + 2; const lh = parseFloat(getComputedStyle(text).lineHeight) || 19; const pad = b.offsetHeight - text.offsetHeight; if (b.offsetHeight && b.offsetTop + b.offsetHeight > limit) - text.style.setProperty('--story-lines', String(Math.max(1, Math.floor((limit - b.offsetTop - pad) / lh)))); + text.style.setProperty( + '--story-lines', + String(Math.max(1, Math.floor((limit - b.offsetTop - pad) / lh + 0.001))) + ); }, /** Canvas click regions become real buttons: focusable, labelled, keyboard-reachable. */ diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index d7a18853..ac3f262f 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -1320,7 +1320,8 @@ describe('story-mode.js speech balloons', () => { }); /** A panel with a box model: JSDOM lays nothing out, so every box the fit reads is given here. */ - function boxes(rt: Record, capHeight: number) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function boxes(rt: Record, capHeight: number, { capWidth = 250, pad = 24 } = {}) { const def = (el: HTMLElement, props: Record number>) => { for (const [k, get] of Object.entries(props)) Object.defineProperty(el, k, { get, configurable: true }); }; @@ -1335,18 +1336,18 @@ describe('story-mode.js speech balloons', () => { offsetTop: () => 10, offsetLeft: () => 10, offsetHeight: () => capHeight, - offsetWidth: () => 250, + offsetWidth: () => capWidth, }); const b = rt.balloon as HTMLElement; const text = b.querySelector('.story-balloon-text') as HTMLElement; - // Three lines of 19 px, fewer once clamped; 24 px of padding and border around them. + // Three lines of 19 px, fewer once clamped; `pad` px of padding and border around them. const lines = () => Number(text.style.getPropertyValue('--story-lines')) || 3; const right = () => parseFloat(b.style.right); def(b, { offsetTop: () => parseFloat(b.style.top) || 0, offsetWidth: () => 200, offsetLeft: () => (Number.isNaN(right()) ? parseFloat(b.style.left) || 0 : 394 - right() - 200), - offsetHeight: () => 24 + lines() * 19, + offsetHeight: () => pad + lines() * 19, }); def(text, { offsetHeight: () => lines() * 19 }); return state; @@ -1378,6 +1379,23 @@ describe('story-mode.js speech balloons', () => { expect(text.style.getPropertyValue('--story-lines')).toBe(''); }); + it('a growth too small for the panel to take never costs the balloon a line', async () => { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + const rt = app._story.panels.find((p: { kind: string }) => p.kind === 'investigate'); + // A short caption: the balloon stands beside it, 10 px down. Two lines need 22 + 38 px and Claude's + // head leaves 59.4 (69.4 - 10): 0.6 px short, under the 2 px a panel ignores. + boxes(rt, 40, { capWidth: 120, pad: 22 }); + app._storyFitCaption(rt); + expect(rt.balloon.style.top).toBe('10px'); + expect(rt.el.style.minHeight).toBe(''); + expect( + (rt.balloon.querySelector('.story-balloon-text') as HTMLElement).style.getPropertyValue('--story-lines') + ).toBe('2'); + }); + it('your balloon stands below a long caption, never under it', async () => { boot(storyOf(demoTurn())); app.openStoryMode(SID);