mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(story-mode): a long caption makes its panel taller instead of covering the art
From review round 11: a long caption ("Claude writes pyproject.toml
and changes test_csvstat.py; the tests pass, then Claude starts a git
repository and saves the work on a new branch …") covered the test
board, the POW and the code card's file name on a two-column panel.
A caption that reaches past the band above the art now makes its panel
taller by the difference (re-fitted on resize and on the Director's cut
toggle). Every scene keeps the panel's usual height as its stage and
stands at the bottom, the background filling the space above, the way
the final panel already did; Claude's balloon is placed against the
moved head. The browser test forces a long caption and checks the panel
grew and the caption ends above the art.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dc980d121c
commit
84c4be2d77
4 files changed
+59
-13
No files matched your search
+1
-1
@@ -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. ⚠️ The final panel's words and chips are IN FLOW (`.story-splash`), so its row grows (`grid-auto-rows: minmax(270px, auto)`) instead of clipping a long ending or a second row of chips; its scene keeps the panel's MIN height 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. ⚠️ 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`, 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).
|
||||
|
||||
**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)
|
||||
|
||||
|
||||
@@ -316,8 +316,12 @@ body.story-open {
|
||||
|
||||
/* Panels */
|
||||
.story-panel {
|
||||
/* The panel's usual height, read back as its scene's stage: a long caption or ending makes the
|
||||
panel taller (inline min-height), and the scene then stands at the bottom, never scaled up. */
|
||||
--story-panel-min: 270px;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
min-height: var(--story-panel-min);
|
||||
overflow: hidden;
|
||||
grid-column: span var(--story-span6, 2);
|
||||
background: var(--story-panel);
|
||||
@@ -547,8 +551,6 @@ body.story-open .toast-container {
|
||||
taller instead of being cut off at its edge. */
|
||||
.story-panel[data-kind='complete'] {
|
||||
display: flex;
|
||||
/* The row's own height, read back as the scene's stage when the words make the panel taller. */
|
||||
min-height: 270px;
|
||||
}
|
||||
.story-splash {
|
||||
position: relative;
|
||||
@@ -663,11 +665,15 @@ body.story-open .toast-container {
|
||||
.story-panel,
|
||||
.story-pencils {
|
||||
grid-column: auto;
|
||||
}
|
||||
.story-panel {
|
||||
--story-panel-min: 300px;
|
||||
}
|
||||
.story-pencils {
|
||||
height: 300px;
|
||||
}
|
||||
.story-panel[data-kind='complete'] {
|
||||
height: auto;
|
||||
min-height: 430px;
|
||||
--story-panel-min: 430px;
|
||||
}
|
||||
.story-splash {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -1883,8 +1883,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
st.dirBtn.setAttribute('aria-pressed', String(on));
|
||||
st.wire.hidden = !on;
|
||||
if (on) this._storyRenderWire();
|
||||
// Within the frame budget, never every panel in one task.
|
||||
for (const rt of st.panels) this._storyQueueDraw(rt);
|
||||
// The other caption has another height: the panels refit to it. Within the frame budget, never
|
||||
// every panel in one task.
|
||||
for (const rt of st.panels) {
|
||||
this._storyFitCaption(rt);
|
||||
this._storyQueueDraw(rt);
|
||||
}
|
||||
},
|
||||
|
||||
_storyPickTurn(id) {
|
||||
@@ -2524,6 +2528,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
rt.capNote.hidden = !notes.length;
|
||||
rt.capNote.textContent = notes.join('; ');
|
||||
}
|
||||
this._storyFitCaption(rt);
|
||||
rt.el.classList.toggle('is-live', !!p.live);
|
||||
// Named by its place only: the caption inside is read as the panel's content, once.
|
||||
rt.el.setAttribute('aria-label', `Panel ${index + 1} of ${page.panels.length}`);
|
||||
@@ -2628,15 +2633,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_storySizePanel(rt) {
|
||||
this._storyFitCaption(rt);
|
||||
const w = rt.el.clientWidth;
|
||||
const h = rt.el.clientHeight;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
if (w === rt.w && h === rt.h && dpr === rt.dpr) return;
|
||||
rt.w = w;
|
||||
rt.h = h;
|
||||
// A final panel its words made taller keeps its scene at the usual size, standing at the bottom:
|
||||
// the stage is the panel's minimum height (inside its border), the rest is background.
|
||||
const minH = rt.splash ? parseFloat(getComputedStyle(rt.el).minHeight) || 0 : 0;
|
||||
// A panel its words made taller (a long caption, the final panel's ending) keeps its scene at the
|
||||
// usual size, standing at the bottom: the stage is the panel's usual height inside its border.
|
||||
const minH = parseFloat(getComputedStyle(rt.el).getPropertyValue('--story-panel-min')) || 0;
|
||||
const border = rt.el.offsetHeight - rt.el.clientHeight;
|
||||
rt.stageH = minH > border ? Math.min(h, minH - border) : h;
|
||||
rt.dpr = dpr;
|
||||
@@ -2646,6 +2652,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._storyQueueDraw(rt);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
_storyFitCaption(rt) {
|
||||
if (rt.splash) return;
|
||||
const cap = rt.capStory.parentElement;
|
||||
const base = parseFloat(getComputedStyle(rt.el).getPropertyValue('--story-panel-min')) || 0;
|
||||
if (!cap || !base) return;
|
||||
const border = rt.el.offsetHeight - rt.el.clientHeight;
|
||||
// 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 grow = need > 2 ? `${Math.ceil(base + need)}px` : '';
|
||||
if (rt.el.style.minHeight !== grow) rt.el.style.minHeight = grow;
|
||||
},
|
||||
|
||||
/** Draws on the next frame, within its budget (now, when the frame loop is not running). */
|
||||
_storyQueueDraw(rt, now = false) {
|
||||
if (now || !this._story?.raf) this._storyDrawPanel(rt);
|
||||
@@ -2721,7 +2744,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
hit
|
||||
);
|
||||
this._storySyncHits(rt, hits, s);
|
||||
this._storyPlaceBalloon(rt, StoryArt.takeSpeaker(), ox, s);
|
||||
this._storyPlaceBalloon(rt, StoryArt.takeSpeaker(), ox, s, oy);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2730,7 +2753,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* 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.
|
||||
*/
|
||||
_storyPlaceBalloon(rt, head, ox, s) {
|
||||
_storyPlaceBalloon(rt, head, ox, s, oy = 0) {
|
||||
const b = rt.balloon;
|
||||
if (!b || b.hidden || rt.splash) return;
|
||||
const placed = !!head && rt.kind !== 'ask' && b.classList.contains('story-balloon--claude');
|
||||
@@ -2770,7 +2793,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
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 * s - 22) : rt.h - 12;
|
||||
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',
|
||||
|
||||
@@ -166,6 +166,23 @@ describe('Story Mode in a real browser', () => {
|
||||
expect(overflow).toBe(0);
|
||||
// The final panel grows to its words and chips: nothing of them is cut off at its edge.
|
||||
expect(await finalPanelHoldsItsWords()).toBe(true);
|
||||
// A caption longer than the band above the art makes its panel taller, so it covers no art.
|
||||
const fit = await page.evaluate(async () => {
|
||||
const st = (window as any).app._story;
|
||||
const rt = st.panels.find((p: { kind: string; splash: unknown }) => !p.splash && p.kind !== 'mission');
|
||||
rt.capStory.textContent = 'Claude writes three files, runs the tests twice and saves the work. '.repeat(4);
|
||||
(window as any).app._storyFitCaption(rt);
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const cap = rt.capStory.parentElement as HTMLElement;
|
||||
const base = parseFloat(getComputedStyle(rt.el).getPropertyValue('--story-panel-min'));
|
||||
const border = rt.el.offsetHeight - rt.el.clientHeight;
|
||||
return {
|
||||
grew: rt.el.offsetHeight > base + 10,
|
||||
clear:
|
||||
cap.offsetTop + cap.offsetHeight <= rt.el.clientHeight - (rt.stageH ?? 0) + (88 * (base - border)) / 260 + 3,
|
||||
};
|
||||
});
|
||||
expect(fit).toEqual({ grew: true, clear: true });
|
||||
// The bar keeps to three rows, and a panel below the fold gets its buttons without a scroll.
|
||||
expect(await page.evaluate(() => (document.querySelector('.story-bar') as HTMLElement).offsetHeight)).toBeLessThan(
|
||||
190
|
||||
|
||||
Reference in new issue
Block a user