Merge branch 'story/balloon-room' into story/integrate

# Conflicts:
#	src/web/public/story-mode.js
This commit is contained in:
Codeman maintainer committed 2026-10-19 06:06:07 +02:00
commit dea8a4d24f
4 files changed
+380 -64

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
**Story Mode** (`story-mode.js` + `story-mode.css`, `showStoryButton`, per-device, default OFF, header button hidden on phones by mobile.css): the active Claude session's turns as comic pages from `GET /api/sessions/:id/story` (server core `src/story-mode.ts`, pure; reader `src/story-reader.ts`). ⚠️ Live refresh is the transcript tool events plus the stop and prompt-submitted hooks (extra `addListener`s in `connectSSE`, so the handler map keeps one method per event) and a 4 s poll while the turn runs, one request in flight, debounced; never the watcher's `transcript:complete`, which interactive transcripts never raise. ⚠️ Every string on the page is the agent's: `textContent` only, containers carry `data-i18n-skip`; a file opens through `openFilePreview()`, never a new route. ⚠️ Canvas click regions become real buttons (`_storySyncHits`), so they are focusable and labelled; a panel keeps its clock across refreshes (only a NEW panel animates in). ⚠️ The overlay owns Escape with a WINDOW capture listener (it runs before app.js's document one): one layer per press: a file preview opened from a panel (it has no Escape of its own outside the folder grid), then the drawer, then the overlay; a folder grid on top and a preview in edit mode keep their own keys. ⚠️ Its comic palette is its own (`--story-*` tokens, `.story-paper` on a light skin), so a skin never repaints it. ⚠️ Wide panels draw their scene in a centered stage (`STORY_STAGE_MAX`); a scene with `fullWidth` (the final splash) uses the whole panel. ⚠️ A panel GROWS to its words instead of covering or clipping them: the final panel's words and chips are IN FLOW (`.story-splash`), and any other panel whose caption reaches past the band above its art gets an inline `min-height` (`_storyFitCaption`, re-run on resize and on the Director's cut toggle); rows grow with them (`grid-auto-rows: minmax(270px, auto)`). Every scene keeps the panel's usual height (`--story-panel-min`) as its stage (`rt.stageH`) and stands at the bottom (`oy`, also passed to `_storyPlaceBalloon`), background filling the rest, or a taller panel would scale Claude up with it. ⚠️ **Save image** composites the page itself (each panel canvas, then every caption, balloon and chip redrawn from its computed style and box, `_storyPaintBox`); never rasterize the DOM through SVG (self-hosted fonts do not reach it). Session Options carries a Story button too (`#sessionOptionsStoryBtn`, same setting), the way in on phones. ⚠️ Claude's speech balloon is placed by `_storyPlaceBalloon()` in the band above the head `agent()` last drew (cyan body only), clamped (`--story-lines`) to stay inside the panel and off the head; never position it per kind in CSS (that is what covered the POW burst and the agent's head on narrow panels). Drawer prose (a prompt, a helper's answer) links rooted paths only. ⚠️ Every file a panel or drawer opens goes through `_storyOpenFile()`: keyboard focus moves to the preview's close button, Tab cycles inside the preview while it is open (`_storyTrapTab`), and Escape gives focus back to what opened it. ⚠️ Panels are kept by IDENTITY (kind + first event id, `rt.identity`), never by index: a fold into a montage near the start shifts every later index and must not rebuild (and re-animate) them. ⚠️ A balloon's text is `.story-balloon-text`, after the visually hidden speaker words (`.story-vh`, "Claude says: "): the clamp and Save image must find the text span by class, never `firstElementChild` (that is how long balloons once stopped clamping). ⚠️ The drawer keeps its nodes when a refresh changes nothing (`st.drawerSig`), and a real change rebuilds and gives focus back to the control that does the same thing; helper steps are filled AFTER that decision. It is bound to its turn (`st.drawer.pageId`): when the newest turn moves on, the page holds that turn (`st.drawerHolds`) until the drawer closes. It starts under the bar (`--story-bar-h`, measured by the ResizeObserver), and where it covers the whole page `.story-main` is `inert` (`_storyDrawerCover`). ⚠️ Text colours come from the `--story-*-text` tokens (4.5:1 in both skins); the accents themselves are for fills and borders. ⚠️ Whatever a scene draws on its canvas goes into **Save image**, a file people share: a command's output reaches the canvas only as a run panel's `printed` lines, filtered server-side by `printableLine()` (story-mode.ts: secret-looking lines dropped, addresses and token-like strings masked); never draw raw output or a drawer's content on a canvas. Tests: `test/story-mode.test.ts`, `test/routes/story-routes.test.ts`, `test/story-mode-ui.test.ts` (JSDOM, every scene drawn in every shape), `test/story-corpus.test.ts` (eleven sanitized real sessions, every page pinned), `test/story-mode.browser.test.ts` (browser suite).
**Story Mode** (`story-mode.js` + `story-mode.css`, `showStoryButton`, per-device, default OFF, header button hidden on phones by mobile.css): the active Claude session's turns as comic pages from `GET /api/sessions/:id/story` (server core `src/story-mode.ts`, pure; reader `src/story-reader.ts`). ⚠️ Live refresh is the transcript tool events plus the stop and prompt-submitted hooks (extra `addListener`s in `connectSSE`, so the handler map keeps one method per event) and a 4 s poll while the turn runs, one request in flight, debounced; never the watcher's `transcript:complete`, which interactive transcripts never raise. ⚠️ Every string on the page is the agent's: `textContent` only, containers carry `data-i18n-skip`; a file opens through `openFilePreview()`, never a new route. ⚠️ Canvas click regions become real buttons (`_storySyncHits`), so they are focusable and labelled; a panel keeps its clock across refreshes (only a NEW panel animates in). ⚠️ The overlay owns Escape with a WINDOW capture listener (it runs before app.js's document one): one layer per press: a file preview opened from a panel (it has no Escape of its own outside the folder grid), then the drawer, then the overlay; a folder grid on top and a preview in edit mode keep their own keys. ⚠️ Its comic palette is its own (`--story-*` tokens, `.story-paper` on a light skin), so a skin never repaints it. ⚠️ Wide panels draw their scene in a centered stage (`STORY_STAGE_MAX`); a scene with `fullWidth` (the final splash) uses the whole panel. ⚠️ A panel GROWS to its words instead of covering or clipping them: the final panel's words and chips are IN FLOW (`.story-splash`), and any other panel whose caption reaches past the band above its art gets an inline `min-height` (`_storyFitCaption`, re-run on resize and on the Director's cut toggle); rows grow with them (`grid-auto-rows: minmax(270px, auto)`). Every scene keeps the panel's usual height (`--story-panel-min`) as its stage (`rt.stageH`) and stands at the bottom (`oy`), background filling the rest, or a taller panel would scale Claude up with it. ⚠️ **Save image** composites the page itself (each panel canvas, then every caption, balloon and chip redrawn from its computed style and box, `_storyPaintBox`); never rasterize the DOM through SVG (self-hosted fonts do not reach it). Session Options carries a Story button too (`#sessionOptionsStoryBtn`, same setting), the way in on phones. ⚠️ Every speech balloon is placed by `_storyFitBalloon()` (called from `_storyFitCaption`) from the scene's layout at its FINISHED frame (`StoryArt.layout`, a dry run on a scratch canvas: never the frame on screen, where an entrance still has objects in flight): Claude's in the band above his head (`agent()` without a `body`, so a cyan helper never holds it), the ask panel's and yours left of his face. A balloon never covers an object a scene registers with `hit()` nor Claude's face; a click region that holds his face is Claude himself and only the face rule applies to it. When there is no room for two lines (a long caption pushes it down), the panel GROWS for caption and balloon together; the growth is computed at the panel's usual height (`storyBalloonRoom`, pure), so a refit of the grown panel asks for the same height and never oscillates; the rest clamps (`--story-lines`, `_storyClampBalloon`). Never position a balloon per kind in CSS (that is what covered the POW burst and the agent's head on narrow panels). Drawer prose (a prompt, a helper's answer) links rooted paths only. ⚠️ Every file a panel or drawer opens goes through `_storyOpenFile()`: keyboard focus moves to the preview's close button, Tab cycles inside the preview while it is open (`_storyTrapTab`), and Escape gives focus back to what opened it. ⚠️ Panels are kept by IDENTITY (kind + first event id, `rt.identity`), never by index: a fold into a montage near the start shifts every later index and must not rebuild (and re-animate) them. ⚠️ A balloon's text is `.story-balloon-text`, after the visually hidden speaker words (`.story-vh`, "Claude says: "): the clamp and Save image must find the text span by class, never `firstElementChild` (that is how long balloons once stopped clamping). ⚠️ The drawer keeps its nodes when a refresh changes nothing (`st.drawerSig`), and a real change rebuilds and gives focus back to the control that does the same thing; helper steps are filled AFTER that decision. It is bound to its turn (`st.drawer.pageId`): when the newest turn moves on, the page holds that turn (`st.drawerHolds`) until the drawer closes. It starts under the bar (`--story-bar-h`, measured by the ResizeObserver), and where it covers the whole page `.story-main` is `inert` (`_storyDrawerCover`). ⚠️ Text colours come from the `--story-*-text` tokens (4.5:1 in both skins); the accents themselves are for fills and borders. ⚠️ Whatever a scene draws on its canvas goes into **Save image**, a file peopLine truncated
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
+3 -3
View File
@@ -481,7 +481,7 @@ body.story-open .toast-container {
top: auto;
bottom: calc(var(--story-compose-h, env(safe-area-inset-bottom, 0px)) + 0.75rem);
}
/* story-mode.js sets --story-lines when a balloon would leave its panel or cover Claude's head. */
/* story-mode.js sets --story-lines when a balloon would leave its panel or cover the art or Claude's face. */
.story-balloon-text {
display: -webkit-box;
-webkit-box-orient: vertical;
@@ -556,8 +556,8 @@ body.story-open .toast-container {
.story-balloon--note::after {
display: none;
}
/* Claude's balloon is placed over its head by story-mode.js (_storyPlaceBalloon); a scene with no
Claude in it (the loop) keeps it in the top corner, clear of the art below. */
/* Every balloon is placed by story-mode.js (_storyFitBalloon), from the scene's layout: these are its
places before that (or without a scene layout). */
.story-panel[data-kind='loop'] .story-balloon--claude {
top: 10px;
right: 12px;
+232 -60
View File
@@ -343,7 +343,10 @@ const StoryArt = (() => {
/** The agent: a terminal-headed character. (x, y) is the point between its feet. */
/** Every edit an event made: an Edit/Write call's one, or the files a Bash here-document wrote. */
const editsOf = (e) => (e?.edit ? [e.edit] : e?.edits || []);
/** Where Claude's head was last drawn (stage units): the speech balloon is placed over it. */
/**
* Where Claude's head was last drawn (stage units): the speech balloon is placed over it, and no
* balloon covers `face`, the box of his head.
*/
let speaker = null;
/** A scene with no Claude in it names the spot its balloon should point at. */
function aim(x, top) {
@@ -360,8 +363,21 @@ const StoryArt = (() => {
const L = o.l ?? [-32, -44];
const R = o.r ?? [32, -44];
const hy = -120 + (o.headBob ?? 0);
// Claude is the cyan one; a helper in another colour never holds the balloon.
if (body === C.cyan) speaker = { x, top: y + (hy - 36) * s };
// Claude is the one drawn in his own colour (no `body` given); a helper never holds the balloon,
// not even the cyan fourth one. `rise`: how much higher a jump lifts him on other frames.
if (!o.body) {
const lift = o.rise ?? 0;
const t = o.tilt ?? 0;
// The head turns with the body about the feet: its centre moves sideways by the tilt.
const cy = (hy + 1) * s;
const fx = x - cy * Math.sin(t);
const fy = y + cy * Math.cos(t) - lift;
speaker = {
x,
top: y + (hy - 36) * s - lift,
face: { x: fx - 33 * s, y: fy - 28 * s, w: 66 * s, h: 56 * s + lift },
};
}
ctx.save();
ctx.translate(x, y);
ctx.rotate(o.tilt ?? 0);
@@ -699,7 +715,10 @@ const StoryArt = (() => {
if (t && !t.passed && !t.failed && t.errors) return `${t.errors} error${t.errors === 1 ? '' : 's'}`;
return `${total} test${total === 1 ? '' : 's'}`;
}
function terminal(ctx, x, y, w, h, lines, f, printed = []) {
/** `clear` ({ x, top, bottom }): a band something is held over (the magnifier); lines in it end left of `x`. */
function terminal(ctx, x, y, w, h, lines, f, printed = [], clear = null) {
const room = (base) =>
clear && base + 2 > clear.top && base - 8 < clear.bottom ? Math.min(w - 18, clear.x - x - 13) : w - 18;
rr(ctx, x, y, w, h, 7);
inked(ctx, C.line, 2.5);
ctx.fillStyle = C.balloonInk;
@@ -714,7 +733,7 @@ const StoryArt = (() => {
ctx.textBaseline = 'alphabetic';
const rows = Math.max(1, Math.floor((h - 24) / 14));
lines.slice(0, rows).forEach((line, i) => {
const text = fitText(ctx, `› ${line}`, w - 18);
const text = fitText(ctx, `› ${line}`, room(y + 30 + i * 14));
const shown = Math.floor(interpolate(f, [8 + i * 12, 20 + i * 12], [0, text.length]));
ctx.fillStyle = i === Math.min(rows, lines.length) - 1 ? C.yellow : C.balloon;
ctx.fillText(text.slice(0, shown), x + 9, y + 30 + i * 14);
@@ -722,7 +741,7 @@ const StoryArt = (() => {
const n = Math.min(rows, lines.length);
const out = printed.slice(0, Math.max(0, rows - n));
out.forEach((line, i) => {
const text = fitText(ctx, line, w - 18);
const text = fitText(ctx, line, room(y + 30 + (n + i) * 14));
const at = 8 + (n + i) * 12;
const shown = Math.floor(interpolate(f, [at, at + 10], [0, text.length]));
ctx.save();
@@ -824,16 +843,12 @@ const StoryArt = (() => {
const files = p.panel.files || [];
const ax = W - 58;
if (!files.length) {
terminal(
ctx,
14,
92,
Math.min(W * 0.58, 220),
130,
p.panel.notes?.length ? p.panel.notes : ['looking around'],
f
);
hit('list', 14, 92, Math.min(W * 0.58, 220), 130, 'See what Claude looked at');
const tw = Math.min(W * 0.58, 220);
// On a narrow panel the magnifier is held over the terminal: the lines it passes end short of
// it (its rim's reach over every frame of its sway), so it never hides their words.
const lens = { x: ax - 122, top: 125, bottom: 187 };
terminal(ctx, 14, 92, tw, 130, p.panel.notes?.length ? p.panel.notes : ['looking around'], f, [], lens);
hit('list', 14, 92, tw, 130, 'See what Claude looked at');
} else {
const shown = files.length > 8 ? files.slice(0, 7) : files;
const extra = files.length - shown.length;
@@ -1138,10 +1153,12 @@ const StoryArt = (() => {
].forEach(([x, y], k) => star4(ctx, x, y, 7 + Math.sin(f / 5 + k * 1.7) * 3, C.yellow));
}
const mood = running ? 'focus' : p.panel.ok ? 'happy' : 'shock';
const jump = p.panel.ok && f > 30 ? Math.abs(Math.sin((f - 30) / 7)) * 12 : 0;
const jumping = p.panel.ok && f > 30;
const jump = jumping ? Math.abs(Math.sin((f - 30) / 7)) * 12 : 0;
agent(ctx, W - 40, 254 - jump, {
frame: f,
scale: 0.66,
rise: jumping ? 12 - jump : 0,
mood,
tilt: p.panel.ok === false ? interpolate(f, [28, 36], [0, 0.14]) : 0,
l: p.panel.ok ? [-32, -58] : [-36, -98],
@@ -1525,7 +1542,33 @@ const StoryArt = (() => {
},
};
return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf, inBand };
/**
* A scene's layout at its finished frame, drawn on a 1x1 scratch canvas: its click regions and where
* Claude stands (stage units). Balloons are placed from this, never from the frame on screen, where
* an entrance still has objects in flight and the panel would change height with them.
*/
let scratch;
function layout(kind, W, p) {
const scene = SCENES[kind];
if (scratch === undefined) {
const c = document.createElement('canvas');
c.width = c.height = 1;
scratch = c.getContext('2d');
}
if (!scene || !scratch) return null;
const hits = [];
takeSpeaker();
scratch.save();
scratch.setTransform(1, 0, 0, 1, 0, 0);
try {
scene.draw(scratch, W, VH, HERO, p, (id, x, y, w, h) => hits.push({ id, x, y, w, h }));
} finally {
scratch.restore();
}
return { hits, speaker: takeSpeaker() };
}
return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf, inBand, layout };
})();
/** Panel kinds as the wire's group headings name them. */
@@ -1684,6 +1727,25 @@ function storyRowHeights(spans, cols, { width, height, gap = 0, stageMin = [], l
return { heights, stage, rowOf, k, fill: avail / n, caps };
}
/**
* How far down a speech balloon may reach, and how much taller its panel must grow for the balloon to
* keep `keep` px (its padding and the lines it must show). Everything is in panel px at the panel's
* usual height: `obstacles` ({ x0, x1, y }: the scene's objects and Claude's face) and `floor` (the
* bottom edge, Claude's head) stand on the stage, so a panel grown by g moves each of them down by g
* while the balloon keeps its `top`. The result therefore does not depend on the grown height: a
* second pass over a grown panel asks for the same growth, never more.
* An obstacle under the body (`x0`..`x1`) must start below it; one under the tail (`tail`: { x0, x1,
* h }, the point below the body) must start below the tail's tip.
*/
function storyBalloonRoom({ top, x0, x1, tail = null, obstacles = [], floor, keep, gap = 4 }) {
let limit = floor;
for (const o of obstacles) {
if (o.x1 > x0 - gap && o.x0 < x1 + gap) limit = Math.min(limit, o.y - gap);
if (tail && o.x1 > tail.x0 - gap && o.x0 < tail.x1 + gap) limit = Math.min(limit, o.y - tail.h - gap);
}
return { limit, grow: Math.max(0, top + keep - limit) };
}
Object.assign(CodemanApp.prototype, {
/** Per-device setting, default OFF. */
isStoryButtonEnabled() {
@@ -3169,6 +3231,8 @@ Object.assign(CodemanApp.prototype, {
/**
* A caption taller than the band above the art makes its panel taller by the difference, and the
* scene stands below it (`rt.stageH`): a long caption never covers a test board or a code page.
* The speech balloon is placed here too (`_storyFitBalloon`), and when it has to sit below the
* caption the panel grows for both, so the balloon covers no object and not Claude's face either.
*/
_storyFitCaption(rt) {
if (rt.splash) return;
@@ -3176,11 +3240,136 @@ Object.assign(CodemanApp.prototype, {
const base = parseFloat(getComputedStyle(rt.el).getPropertyValue('--story-panel-min')) || 0;
if (!cap || !base) return;
const border = rt.el.offsetHeight - rt.el.clientHeight;
const s = (base - border) / StoryArt.VH;
// The art starts about 88 scene units down (a terminal at 92, a code page at 104, a board at 106).
const room = (88 * (base - border)) / StoryArt.VH;
const need = cap.offsetTop + cap.offsetHeight - room;
const capBottom = cap.offsetTop + cap.offsetHeight;
// Never more than one more usual height for the balloon: a guard, the room it asks for is smaller.
const balloon = Math.min(base, this._storyFitBalloon(rt, base - border, s, cap));
const need = Math.max(capBottom - 88 * s, balloon);
const grow = need > 2 ? `${Math.ceil(base + need)}px` : '';
if (rt.el.style.minHeight !== grow) rt.el.style.minHeight = grow;
if (rt.balloonFit) this._storyClampBalloon(rt, rt.el.clientHeight);
},
/**
* The scene's objects and Claude in panel px at the panel's usual height, from the scene's finished
* frame (`StoryArt.layout`), kept until the panel's data, width or mode changes. A click region that
* holds Claude's face is Claude himself (the mission's prompt, the ask panel's question): it is not
* an object a balloon must avoid, only his face is.
*/
_storySceneLayout(rt, w, s) {
const st = this._story;
const key = `${rt.kind}|${w}|${s}|${st.director}`;
if (rt.layoutKey === key && rt.layoutFor === rt.panel) return rt.layout;
const scene = StoryArt.SCENES[rt.kind];
const W = w / s;
const stageW = scene?.fullWidth ? W : Math.min(W, STORY_STAGE_MAX);
const ox = (W - stageW) / 2;
const raw =
rt.panel && StoryArt.layout(rt.kind, stageW, { panel: rt.panel, page: st.data?.page, director: st.director });
let lay = null;
if (raw) {
const box = (r) => ({ x0: (r.x + ox) * s, x1: (r.x + ox + r.w) * s, y: r.y * s, y1: (r.y + r.h) * s });
const sp = raw.speaker;
const face = sp?.face ? box(sp.face) : null;
const cx = face && (face.x0 + face.x1) / 2;
const cy = face && (face.y + face.y1) / 2;
const objects = raw.hits.map(box).filter((o) => !(face && o.x0 <= cx && cx <= o.x1 && o.y <= cy && cy <= o.y1));
lay = { objects, face, head: sp ? { x: (sp.x + ox) * s, top: sp.top * s } : null };
}
rt.layoutKey = key;
rt.layoutFor = rt.panel;
rt.layout = lay;
return lay;
},
/**
* Places the balloon (left or right, top, max-width; nothing of it reads the panel's height) and
* returns how much taller the panel must be for the balloon to show two lines (or all of a shorter
* one) above every object it would cover and above Claude's face. Claude's own balloon goes in the
* band above his head, the tail pointing down at it: beside the caption when there is room, else
* below it. The ask panel's stays left of his face; yours and a notice stand under the caption.
* The room it may use is kept in `rt.balloonFit` for the clamp (`_storyClampBalloon`).
*/
_storyFitBalloon(rt, stageH, s, cap) {
const b = rt.balloon;
const text = b?.querySelector('.story-balloon-text');
rt.balloonFit = null;
if (!b || b.hidden || !text) return 0;
const w = rt.el.clientWidth;
const lay = this._storySceneLayout(rt, w, s);
if (!lay) {
// No scene layout (a browser out of canvas memory): the stylesheet's places, clamped to the panel.
for (const prop of ['left', 'right', 'top', 'max-width']) b.style.removeProperty(prop);
return 0;
}
const capRight = cap.offsetLeft + cap.offsetWidth;
const capBottom = cap.offsetTop + cap.offsetHeight;
const face = lay.face;
const claude = b.classList.contains('story-balloon--claude');
const placed = claude && rt.kind !== 'ask' && !!lay.head;
let floor = stageH - 12;
let tail = null;
if (placed) {
const hx = lay.head.x;
const right = Math.max(10, Math.min(w - 160, w - hx - 38));
// The loop has no Claude to keep clear of on the left: its balloon may run wide, so it stays
// short and ends above the board it points at.
let maxW = Math.min(w * (rt.kind === 'loop' ? 0.9 : 0.5), w - right - 14);
let top = 10;
const besideCap = w - right - capRight - 10;
if (besideCap >= 150) maxW = Math.min(maxW, besideCap);
else top = capBottom + 8;
// The LIVE tag sits in the top-right corner: start below it when the balloon reaches that far.
if (!rt.liveTag.hidden && right < w - rt.liveTag.offsetLeft)
top = Math.max(top, rt.liveTag.offsetTop + rt.liveTag.offsetHeight + 6);
b.style.left = 'auto';
b.style.right = `${Math.round(right)}px`;
b.style.top = `${Math.round(top)}px`;
b.style.maxWidth = `${Math.round(Math.max(140, maxW))}px`;
// The tail's tip ends just above the head (it points at it).
floor = Math.min(floor, lay.head.top - 22);
} else {
// The ask panel's balloon and yours stand at the left: under the caption, and left of his face.
const left = claude ? 14 : 16;
const top = Math.max(claude ? 0.3 * stageH : 74, capBottom + (claude ? 8 : 10));
let maxW = w * (claude ? 0.46 : 0.54);
if (face && face.x0 - 8 - left < maxW) maxW = Math.max(120, face.x0 - 8 - left);
b.style.left = `${left}px`;
b.style.right = 'auto';
b.style.top = `${Math.round(top)}px`;
b.style.maxWidth = `${Math.round(maxW)}px`;
}
text.style.removeProperty('--story-lines');
const top = b.offsetTop;
const x0 = b.offsetLeft;
const x1 = x0 + b.offsetWidth;
const lh = parseFloat(getComputedStyle(text).lineHeight) || 19;
const pad = b.offsetHeight - text.offsetHeight;
if (claude) {
// The tail (the ink half, ::before) hangs below the body's right end, set inside its border.
const bs = getComputedStyle(b);
const tb = getComputedStyle(b, '::before');
const inset = parseFloat(bs.borderRightWidth) || 0;
const right = x1 - inset - (parseFloat(tb.right) || 0);
const below = -(parseFloat(tb.bottom) || 0) - (parseFloat(bs.borderBottomWidth) || 0);
if (below > 0 && tb.display !== 'none') tail = { x0: right - (parseFloat(tb.width) || 0), x1: right, h: below };
}
const obstacles = lay.objects.slice();
if (face) obstacles.push(face);
// The ask panel's answer stamp stands at the bottom, so it moves with a taller panel like the art.
if (rt.answer && !rt.answer.hidden)
obstacles.push({
x0: rt.answer.offsetLeft,
x1: rt.answer.offsetLeft + rt.answer.offsetWidth,
y: stageH - (rt.el.clientHeight - rt.answer.offsetTop) - 6,
});
const lines = Math.max(1, Math.round(text.offsetHeight / lh));
const keep = pad + Math.min(2, lines) * lh;
const room = storyBalloonRoom({ top, x0, x1, tail, obstacles, floor, keep });
rt.balloonSerial = (rt.balloonSerial || 0) + 1;
rt.balloonFit = { top, limit: room.limit, stageH, serial: rt.balloonSerial };
return room.grow;
},
/** Draws on the next frame, within its budget (now, when the frame loop is not running). */
@@ -3260,60 +3449,43 @@ Object.assign(CodemanApp.prototype, {
hit
);
this._storySyncHits(rt, hits, s);
this._storyPlaceBalloon(rt, StoryArt.takeSpeaker(), ox, s, oy);
StoryArt.takeSpeaker(); // the balloon is placed from the scene's layout (_storyFitBalloon), not this frame
this._storyPlaceBalloon(rt);
},
/**
* Claude's balloon goes in the band above its head, the tail pointing down at it, so it never
* covers the art the scene draws below (a test board, a burst, the code page). It keeps clear of
* the caption box: narrower beside it, or below it when there is no room. Recomputed only when
* the panel's size, the text or the head's column changes.
* Keeps the balloon's clamp right for the panel's height, which can change without a refit (a
* taller neighbour stretches the row). Cheap when nothing changed: it runs on every draw.
*/
_storyPlaceBalloon(rt, head, ox, s, oy = 0) {
_storyPlaceBalloon(rt) {
const b = rt.balloon;
if (!b || b.hidden || rt.splash) return;
const placed = !!head && rt.kind !== 'ask' && b.classList.contains('story-balloon--claude');
const hx = placed ? (head.x + ox) * s : 0;
const cap = rt.capStory.parentElement;
const capW = cap?.offsetWidth || 0;
const live = !rt.liveTag.hidden;
const key = `${rt.w}x${rt.h}|${placed ? Math.round(hx / 6) : '-'}|${capW}|${live}|${b.className}|${b.textContent}`;
const key = `${rt.h}|${rt.balloonFit?.serial ?? '-'}|${b.className}|${b.textContent}`;
if (key === rt.balloonKey) return;
rt.balloonKey = key;
if (placed) {
const capRight = cap ? cap.offsetLeft + capW : 0;
const capBottom = cap ? cap.offsetTop + cap.offsetHeight : 0;
const right = Math.max(10, Math.min(rt.w - 160, rt.w - hx - 38));
// The loop has no Claude to keep clear of on the left: its balloon may run wide, so it stays
// short and ends above the board it points at.
let maxW = Math.min(rt.w * (rt.kind === 'loop' ? 0.9 : 0.5), rt.w - right - 14);
let top = 10;
const besideCap = rt.w - right - capRight - 10;
if (besideCap >= 150) maxW = Math.min(maxW, besideCap);
else top = capBottom + 8;
// The LIVE tag sits in the top-right corner: start below it when the balloon reaches that far.
if (live && right < rt.w - rt.liveTag.offsetLeft)
top = Math.max(top, rt.liveTag.offsetTop + rt.liveTag.offsetHeight + 6);
b.style.left = 'auto';
b.style.right = `${Math.round(right)}px`;
b.style.top = `${Math.round(top)}px`;
b.style.maxWidth = `${Math.round(Math.max(140, maxW))}px`;
} else {
for (const prop of ['left', 'right', 'top', 'max-width']) b.style.removeProperty(prop);
}
// Clamp a long text: inside the panel always, and off Claude's head when three lines allow.
const text = b.querySelector('.story-balloon-text');
this._storyClampBalloon(rt, rt.h);
},
/**
* Clamps a long balloon text (`--story-lines`) to the room its fit left it at panel height `h`: a
* taller panel moves the scene's objects and Claude down, so the room grows with it. Without a fit
* (no stylesheet, no scene layout) the text only stays inside the panel.
*/
_storyClampBalloon(rt, h) {
const b = rt.balloon;
const text = b?.querySelector('.story-balloon-text');
if (!text) return;
const fit = rt.balloonFit;
rt.balloonKey = `${h}|${fit?.serial ?? '-'}|${b.className}|${b.textContent}`;
text.style.removeProperty('--story-lines');
// 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;
// The loop's "head" is its board: down to one line rather than over the test count.
const least = rt.kind === 'loop' ? 1 : placed ? 3 : 2;
const limit = placed ? Math.max(b.offsetTop + pad + least * lh, (head.top + oy) * s - 22) : rt.h - 12;
if (b.offsetHeight && b.offsetTop + b.offsetHeight > limit)
text.style.setProperty(
'--story-lines',
String(Math.max(Math.min(least, 2), Math.floor((limit - b.offsetTop - pad) / lh)))
String(Math.max(1, Math.floor((limit - b.offsetTop - pad) / lh + 0.001)))
);
},
+144
View File
@@ -1970,3 +1970,147 @@ describe('story-mode.js reading levels', () => {
expect(chips()).toEqual(['7 min 12 s', '1 file changed', '10 tool calls', '1 commit', '1 helper']);
});
});
describe('story-mode.js speech balloons', () => {
const room = (o: Record<string, unknown>) => win.storyBalloonRoom(o) as { limit: number; grow: number };
it('a balloon reaches down to the first object under it, its tail to the first under the tail', () => {
boot(null);
const base = { top: 10, x0: 100, x1: 300, floor: 250, keep: 62 };
expect(room(base)).toEqual({ limit: 250, grow: 0 });
// Under the body: the balloon ends above it (4 px gap).
expect(room({ ...base, obstacles: [{ x0: 200, x1: 260, y: 104 }] }).limit).toBe(100);
// Beside it (more than the gap away): ignored.
expect(room({ ...base, obstacles: [{ x0: 20, x1: 90, y: 40 }] }).limit).toBe(250);
// Under the tail only: the tail's tip ends above it, so the body ends a tail higher.
const tail = { x0: 330, x1: 352, h: 16.5 };
expect(room({ ...base, tail, obstacles: [{ x0: 340, x1: 400, y: 120 }] }).limit).toBe(120 - 16.5 - 4);
// No room for two lines below a long caption: the panel grows by the shortfall.
expect(room({ ...base, top: 168, obstacles: [{ x0: 200, x1: 260, y: 104 }] })).toEqual({ limit: 100, grow: 130 });
});
/** A panel with a box model: JSDOM lays nothing out, so every box the fit reads is given here. */
// 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 });
};
const state = { grown: 0 };
rt.el.style.setProperty('--story-panel-min', '270px');
def(rt.el, {
offsetHeight: () => 270 + state.grown,
clientHeight: () => 264 + state.grown,
clientWidth: () => 394,
});
def(rt.capStory.parentElement, {
offsetTop: () => 10,
offsetLeft: () => 10,
offsetHeight: () => capHeight,
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; `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: () => pad + lines() * 19,
});
def(text, { offsetHeight: () => lines() * 19 });
return state;
}
it('a balloon pushed below a long caption grows its panel, the same growth on the grown panel', async () => {
boot(storyOf(demoTurn()));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const rt = app._story.panels.find((p: { kind: string }) => p.kind === 'investigate');
const box = boxes(rt, 150);
app._storyFitCaption(rt);
// No room beside the caption: the balloon stands below it, over Claude's head at the right.
expect(rt.balloon.style.top).toBe('168px');
const grown = rt.el.style.minHeight;
// Two lines (24 + 38 px) end above Claude's head (91 px down the usual stage, the tail 22 px
// over it), so the panel grows by 168 + 62 - 69: more than the caption alone asks for (71).
expect(grown).toBe('431px');
// The resize observer refits the grown panel: the same height, and the text clamps to two lines.
box.grown = parseFloat(grown) - 270;
app._storyFitCaption(rt);
expect(rt.el.style.minHeight).toBe(grown);
const text = rt.balloon.querySelector('.story-balloon-text') as HTMLElement;
expect(text.style.getPropertyValue('--story-lines')).toBe('2');
// A taller row (a neighbour grew more) gives the text its third line back.
rt.h = 264 + box.grown + 40;
app._storyPlaceBalloon(rt);
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);
await settle();
sizeAndDraw();
const rt = app._story.panels.find((p: { kind: string }) => p.kind === 'mission');
boxes(rt, 120);
app._storyFitCaption(rt);
expect(rt.balloon.className).toContain('story-balloon--you');
expect(rt.balloon.style.top).toBe('140px');
expect(rt.balloon.style.left).toBe('16px');
// The mission's click region is Claude himself: not an object to avoid, only his face is.
expect(rt.layout.objects).toEqual([]);
// Claude stands at the right: the balloon ends left of his face.
const face = rt.layout.face;
expect(16 + parseFloat(rt.balloon.style.maxWidth)).toBeLessThanOrEqual(face.x0 - 8);
});
});
describe('story-mode.js magnifier', () => {
it('terminal lines the magnifier is held over end short of it, the others keep their width', async () => {
const look = (id: string, desc: string) => bash(id, `ls -la ${id}`, desc, '');
boot(
storyOf([
prompt('Look around please'),
...look('b1', 'Inspect project folder, git state and the remotes'),
...look('b2', 'List src and config contents of the whole repository'),
end(4000),
])
);
app.openStoryMode(SID);
await settle();
const rt = app._story.panels.find((p: { kind: string }) => p.kind === 'investigate');
expect(rt.panel.files ?? []).toEqual([]);
// A narrow panel: the stage is 300 / (270 / 260) = 289 units wide, Claude at 231, the lens's rim
// at 231 - 119 at most, the terminal 168 units wide from 14.
Object.assign(rt, { w: 300, h: 270, dpr: 1, bgKey: '', clock: 72 }); // the finished frame (HERO)
drawnTexts.length = 0;
app._storyDrawPanel(rt);
const lines = drawnTexts.filter((t) => t.startsWith('› '));
expect(lines).toHaveLength(2);
// The fake context measures 6 units a character: the first line uses the terminal's width (above
// the lens), the second ends with an ellipsis before the rim (231 - 122 - 14 - 13 = 82 units).
expect(lines[0].length * 6).toBeGreaterThan(120);
expect(lines[1].endsWith('…')).toBe(true);
expect(lines[1].length * 6).toBeLessThanOrEqual(82);
});
});