fix(story-mode): a growth too small to apply never costs a balloon a line

A panel ignores a growth of 2 px or less, but the clamp measured against
the exact room, so a balloon that fell 0.6 px short of two lines showed
one. The clamp now lets a balloon use those 2 px of the 4 px gap it keeps
from the art and Claude's head, which is what the ignored growth would
have bought, and rounds away float error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-19 06:04:20 +02:00
1 parent 0bf6b77170
commit 09af5c3dc8
2 files changed
+29 -6

No files matched your search

+7 -2
View File
@@ -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. */
+22 -4
View File
@@ -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<string, any>, capHeight: number) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function boxes(rt: Record<string, any>, capHeight: number, { capWidth = 250, pad = 24 } = {}) {
const def = (el: HTMLElement, props: Record<string, () => 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);