mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(story-mode): the final panel grows to its words, never clipping a chip or a long ending
From review round 10: the chip "1 commit, copied to remote.git" was cut off at the final panel's edge (on four of the eleven sessions), and so would any ending longer than the panel's fixed 270 px. The final panel's words now sit in flow, so its row grows (and a phone's 430 px is a minimum). Its scene keeps the usual size, the panel's minimum height as its stage, standing at the bottom with the background filling the rest; otherwise a taller panel scaled Claude up with it. On a phone the words end above Claude. The browser test fills the final balloon with a long ending and checks every chip, the balloon and the title lie inside the panel, at desktop and phone width; it fails on the old layout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f46369a5af
commit
05dc6fbd95
4 files changed
+61
-18
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. ⚠️ **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. ⚠️ 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).
|
||||
|
||||
**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)
|
||||
|
||||
|
||||
@@ -308,7 +308,8 @@ body.story-open {
|
||||
.story-page {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-auto-rows: 270px;
|
||||
/* A row grows only for the final panel, whose words and chips are in flow (never clipped). */
|
||||
grid-auto-rows: minmax(270px, auto);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
@@ -542,13 +543,20 @@ body.story-open .toast-container {
|
||||
background: var(--story-live-bg);
|
||||
}
|
||||
|
||||
/* The final panel's words sit in flow, so a long ending or a second row of chips makes the panel
|
||||
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: absolute;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 46%;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 46%;
|
||||
max-width: 46%;
|
||||
margin: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -658,12 +666,15 @@ body.story-open .toast-container {
|
||||
height: 300px;
|
||||
}
|
||||
.story-panel[data-kind='complete'] {
|
||||
height: 430px;
|
||||
height: auto;
|
||||
min-height: 430px;
|
||||
}
|
||||
.story-splash {
|
||||
right: 12px;
|
||||
bottom: auto;
|
||||
width: auto;
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
justify-content: flex-start;
|
||||
/* Claude stands centred at the bottom on a phone: the words end above it. */
|
||||
margin-bottom: 170px;
|
||||
}
|
||||
.story-splash-title {
|
||||
font-size: 36px;
|
||||
|
||||
@@ -1423,7 +1423,8 @@ const StoryArt = (() => {
|
||||
fullWidth: true,
|
||||
bg(ctx, W, H) {
|
||||
base(ctx, W, H);
|
||||
rays(ctx, W, H, W > 420 ? W * 0.72 : W / 2, 200, 30, C.cyan, 0.32);
|
||||
// Centred on Claude, who stands at the bottom of the stage (H - 60 is 200 on a normal panel).
|
||||
rays(ctx, W, H, W > 420 ? W * 0.72 : W / 2, H - 60, 30, C.cyan, 0.32);
|
||||
halftone(ctx, W, H, C.magenta, W, 0, { rmax: 3.4, reach: 0.85, alpha: 0.75 });
|
||||
},
|
||||
draw(ctx, W, H, f, p) {
|
||||
@@ -2612,6 +2613,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
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;
|
||||
const border = rt.el.offsetHeight - rt.el.clientHeight;
|
||||
rt.stageH = minH > border ? Math.min(h, minH - border) : h;
|
||||
rt.dpr = dpr;
|
||||
rt.canvas.width = Math.max(1, Math.round(w * dpr));
|
||||
rt.canvas.height = Math.max(1, Math.round(h * dpr));
|
||||
@@ -2630,8 +2636,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!rt.panel || !rt.w || !rt.h) return;
|
||||
const scene = StoryArt.SCENES[rt.kind];
|
||||
if (!scene) return;
|
||||
const s = rt.h / StoryArt.VH;
|
||||
const s = (rt.stageH || rt.h) / StoryArt.VH;
|
||||
const W = rt.w / s;
|
||||
// Scene units down the whole panel: VH, or more on a final panel its words made taller, whose
|
||||
// stage then stands at the bottom (oy) with background above it.
|
||||
const H = rt.h / s;
|
||||
const oy = H - StoryArt.VH;
|
||||
const k = rt.dpr * s;
|
||||
const key = `${rt.kind}|${rt.canvas.width}x${rt.canvas.height}|${StoryArt.C.key}`;
|
||||
if (rt.bgKey !== key) {
|
||||
@@ -2657,14 +2667,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Without a frame loop (tests, a browser that never paints) the whole background at once.
|
||||
const steps = st?.raf && !full ? 1 : job.total - job.next;
|
||||
for (let i = 0; i < steps && b; i++, job.next++) {
|
||||
const top = (StoryArt.VH / job.total) * job.next;
|
||||
const bottom = top + StoryArt.VH / job.total;
|
||||
const top = (H / job.total) * job.next;
|
||||
const bottom = top + H / job.total;
|
||||
b.save();
|
||||
b.setTransform(k, 0, 0, k, 0, 0);
|
||||
b.beginPath();
|
||||
b.rect(0, top, W, bottom - top);
|
||||
b.clip();
|
||||
StoryArt.inBand(top, bottom, () => scene.bg(b, W, StoryArt.VH));
|
||||
StoryArt.inBand(top, bottom, () => scene.bg(b, W, H));
|
||||
b.restore();
|
||||
}
|
||||
if (!b) job.next = job.total;
|
||||
@@ -2677,9 +2687,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
ctx.drawImage(job.canvas, 0, 0);
|
||||
const stageW = scene.fullWidth ? W : Math.min(W, STORY_STAGE_MAX);
|
||||
const ox = (W - stageW) / 2;
|
||||
ctx.setTransform(k, 0, 0, k, ox * k, 0);
|
||||
ctx.setTransform(k, 0, 0, k, ox * k, oy * k);
|
||||
const hits = [];
|
||||
const hit = (id, x, y, w, h, label) => hits.push({ id, x: x + ox, y, w, h, label });
|
||||
const hit = (id, x, y, w, h, label) => hits.push({ id, x: x + ox, y: y + oy, w, h, label });
|
||||
StoryArt.takeSpeaker();
|
||||
scene.draw(
|
||||
ctx,
|
||||
|
||||
Reference in new issue
Block a user