From 6e45831909d230b94ed281dba4e13b8b84dab5a6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 13:51:11 +0200 Subject: [PATCH] fix(story-mode): read right by a screen reader, and the last of review round 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessibility (review6-c): - A panel is named "Panel 2 of 7" and its caption is read once, as its content (it was the name and the content both). - Balloons say who speaks, visually hidden ("Claude says: ", "You say: ", "Claude asks: ", "Notice: "); Save image leaves those words out. - The overlay is named "Story Mode: ", the drawer by its kind and title ("Diff 2 files"), the final panel's chips by what they open, and the wire's panel labels are headings. - While a file preview opened from a panel is up, the overlay is not aria-modal (the preview sits beside it), and is again once focus returns. - The focus ring on a click region gets a light band under its dashes; on the loop's magenta halftone it vanished. From review6-b: - "Mission complete!" no longer shows while a helper or a background command is still out ("To be continued…"). - A background command's drawer says "Started in the background…", then "Finished in the background, exit code 0…", instead of the launch note meant for Claude ("do not poll that file"). - `sed -n '5,25p' README.md` is a printed file, offered in the drawer. - A report turn no longer counts the helper it answers as one it started. - A helper's `cd /work/x && ls` keeps its folder as ".". Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 10 +++- src/web/public/story-mode.css | 11 +++- src/web/public/story-mode.js | 50 ++++++++++++++++--- ...ash-script-background-helper.expected.json | 4 +- .../text-plan-dry-run-helper.expected.json | 4 +- test/story-mode-ui.test.ts | 10 +++- test/story-mode.test.ts | 11 ++++ 7 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index 499870d2..680d79a2 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -503,7 +503,9 @@ export function printedFiles(command: string): string[] { for (const segment of commandSegments(withoutHeredocBodies(command.slice(0, 2000)))) { const head = segmentHead(segment); if (/^(cd|pushd)\b/.test(head)) return []; - const m = /^(cat|head|tail|nl|bat)\s+(.*)$/.exec(head); + // `sed -n '5,25p' README.md` prints part of a file, like head and tail. + const sed = /^sed\s+-n\s+(['"]?)[0-9,$]+p\1\s+(.+)$/.exec(head); + const m = sed ? [head, 'sed', sed[2]] : /^(cat|head|tail|nl|bat)\s+(.*)$/.exec(head); if (!m || /[<>]/.test(m[2])) continue; const words = m[2].match(/"[^"]*"|'[^']*'|\S+/g) ?? []; for (let k = 0; k < words.length; k++) { @@ -1083,6 +1085,9 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean if (tail.truncated) result.outputTruncated = true; if (name === 'Bash' && /^\s*Command running in background with ID:/.test(text)) { result.background = true; + // The launch note tells Claude where to read later; the reader gets what it means. + result.output = 'Started in the background. Its result comes back as a report of its own.'; + result.outputTruncated = undefined; } else if (name === 'Bash') { // Counted on the whole output: a later command's lines can push the summary out of the tail. const tests = parseTestCounts(text); @@ -1519,6 +1524,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { const code = /\(exit code (\d+)\)/.exec(prompt.label)?.[1]; ev.ok = code !== undefined ? code === '0' : !report.status || /^(completed|done|finished|success)/i.test(report.status); + ev.output = `Finished in the background${code !== undefined ? `, exit code ${code}` : ''}. Its report is the turn it came back in.`; return; } if (!ev?.helper) return; @@ -2393,7 +2399,7 @@ export function buildStoryPage(turn: StoryTurn): StoryPage { added: edits.reduce((n, e) => n + e.added, 0), removed: edits.reduce((n, e) => n + e.removed, 0), // The advisor is a second opinion, not a helper that does work. - helpers: events.filter((e) => e.helper && e.tool !== 'Advisor').length, + helpers: events.filter((e) => e.helper && e.tool !== 'Advisor' && e.intent !== 'prompt').length, helpersRunning: events.filter((e) => e.helper?.running).length, }; const calls = events.filter((e) => !NOT_TOOL_CALLS.has(e.intent)); diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index 962d3ac6..80084669 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -345,6 +345,10 @@ body.story-open { outline: 3px dashed var(--story-magenta) !important; outline-offset: 2px; } +/* A light band under the dashes: on a magenta halftone (the loop) the ring alone vanished. */ +.story-hit:focus-visible { + box-shadow: 0 0 0 5px var(--story-balloon); +} .story-cap { position: absolute; z-index: 2; @@ -393,7 +397,8 @@ body.story-open { cursor: pointer; } /* Spoken, not shown: the status region a screen reader announces from. */ -.story-sr { +.story-sr, +.story-vh { position: absolute; width: 1px; height: 1px; @@ -702,6 +707,10 @@ body.story-open .toast-container { font: 16px/1.1 var(--story-letter); color: var(--story-muted); } +.story-wire-group h3 { + margin: 0; + font: inherit; +} .story-ev { display: grid; grid-template-columns: 62px minmax(0, 1fr); diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 5118d57d..236bba7f 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -1586,6 +1586,7 @@ Object.assign(CodemanApp.prototype, { st.sessionLabel.textContent = session ? this.getSessionName?.(session) || session.name || sessionId.slice(0, 8) : sessionId.slice(0, 8); + st.root.setAttribute('aria-label', `Story Mode: ${st.sessionLabel.textContent}`); this._storyRender(); this._storyStartRuntime(); this._storyRefresh(); @@ -1649,7 +1650,13 @@ Object.assign(CodemanApp.prototype, { root.hidden = true; root.setAttribute('role', 'dialog'); root.setAttribute('aria-modal', 'true'); - root.setAttribute('aria-labelledby', 'storyTitle'); + root.setAttribute('aria-label', 'Story Mode'); + // A file preview opened from a panel sits beside the overlay, not in it: while it is up the + // overlay is no longer modal, or a screen reader that honours aria-modal would hide it. + root.addEventListener('focusin', () => { + if (!document.getElementById('filePreviewOverlay')?.classList.contains('visible')) + root.setAttribute('aria-modal', 'true'); + }); const bar = el('div', 'story-bar'); const brand = el('div', 'story-brand'); @@ -1731,7 +1738,8 @@ Object.assign(CodemanApp.prototype, { drawerMeta.append(drawerKind, drawerLive); const drawerTitle = el('p', 'story-drawer-title'); drawerTitle.id = 'storyDrawerTitle'; - drawer.setAttribute('aria-labelledby', 'storyDrawerTitle'); + drawerKind.id = 'storyDrawerKind'; + drawer.setAttribute('aria-labelledby', 'storyDrawerKind storyDrawerTitle'); const drawerSub = el('p', 'story-drawer-sub'); drawerTitle.dataset.i18nSkip = ''; drawerSub.dataset.i18nSkip = ''; @@ -2370,7 +2378,7 @@ Object.assign(CodemanApp.prototype, { const balloonText = p.kind === 'ask' && p.question?.text ? p.question.text : p.balloon?.text; rt.balloon.hidden = !balloonText; // The text sits in its own box so a long one can be clamped without clipping the tail. - let balloonSpan = rt.balloon.firstElementChild; + let balloonSpan = rt.balloon.querySelector('.story-balloon-text'); if (!balloonSpan) { balloonSpan = document.createElement('span'); balloonSpan.className = 'story-balloon-text'; @@ -2380,6 +2388,20 @@ Object.assign(CodemanApp.prototype, { // A prompt somebody else sent (a background job, another session) is a notice, not your speech. const origin = page.events[p.events[0]]?.origin ?? (index === 0 ? page.origin : 'human'); const notice = p.kind === 'mission' && origin && origin !== 'human'; + // Who speaks, for a screen reader: the art shows it, the text alone does not. + let who = rt.balloon.querySelector('.story-vh'); + if (!who) { + who = document.createElement('span'); + who.className = 'story-vh'; + rt.balloon.prepend(who); + } + who.textContent = notice + ? 'Notice: ' + : p.kind === 'mission' || p.balloon?.who === 'you' + ? 'You say: ' + : p.kind === 'ask' + ? 'Claude asks: ' + : 'Claude says: '; rt.balloon.onclick = p.kind === 'mission' ? () => @@ -2397,7 +2419,8 @@ Object.assign(CodemanApp.prototype, { }`; rt.liveTag.hidden = !p.live; rt.el.classList.toggle('is-live', !!p.live); - rt.el.setAttribute('aria-label', `Panel ${index + 1}: ${p.caption.story}`); + // 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}`); if (rt.answer) { rt.answer.hidden = !p.question?.answer; const planned = page.events[p.events[0]]?.tool === 'ExitPlanMode'; @@ -2416,7 +2439,9 @@ Object.assign(CodemanApp.prototype, { ? 'To be continued…' : page.noneWorked ? 'Turn over' - : 'Mission complete!'; + : page.helpersRunning || page.events.some((e) => e.running && !e.helper) + ? 'To be continued…' + : 'Mission complete!'; const chips = rt.splash.querySelector('.story-chips'); const items = []; if (page.durationMs !== undefined) items.push({ text: this._storyDuration(page.durationMs) }); @@ -2443,6 +2468,10 @@ Object.assign(CodemanApp.prototype, { if (item.open) { inner.type = 'button'; inner.dataset.storyOpen = item.open; + inner.setAttribute( + 'aria-label', + `${item.text}: ${item.open.startsWith('diff') ? 'see the changes' : 'list every step'}` + ); } inner.textContent = item.text; li.append(inner); @@ -2777,7 +2806,9 @@ Object.assign(CodemanApp.prototype, { } } // The text the reader sees (hidden spans, like the other reading level, are left out). - const text = (el.innerText || el.textContent || '').trim(); + let text = (el.innerText || el.textContent || '').trim(); + const spoken = el.querySelector?.('.story-vh'); + if (spoken) text = text.replace(spoken.textContent.trim(), '').trim(); if (!text) return; const size = parseFloat(cs.fontSize) || 14; const lh = parseFloat(cs.lineHeight) || size * 1.15; @@ -2857,7 +2888,9 @@ Object.assign(CodemanApp.prototype, { if (pi !== lastPanel && pi >= 0) { const g = document.createElement('li'); g.className = 'story-wire-group'; - g.textContent = `Panel ${pi + 1} · ${STORY_KIND_NAMES[page.panels[pi].kind] || page.panels[pi].kind}`; + const h = document.createElement('h3'); + h.textContent = `Panel ${pi + 1} · ${STORY_KIND_NAMES[page.panels[pi].kind] || page.panels[pi].kind}`; + g.append(h); rows.push(g); lastPanel = pi; } @@ -2984,6 +3017,7 @@ Object.assign(CodemanApp.prototype, { const st = this._story; if (!st || !path) return; st.previewReturn = trigger || document.activeElement; + st.root.setAttribute('aria-modal', 'false'); this.openFilePreview?.(path, st.sessionId); requestAnimationFrame(() => document.querySelector('#filePreviewOverlay.visible button[title="Close"]')?.focus({ preventScroll: true }) @@ -3334,7 +3368,7 @@ Object.assign(CodemanApp.prototype, { const li = el('li', 'story-ev'); li.dataset.actor = 'helper'; const body = el('span', 'story-ev-body'); - const shown = this._storyShortPath(ev.label); + const shown = this._storyShortPath(ev.label, { dot: ev.tool === 'Bash' }); const label = ev.file ? el('button', 'story-link', shown) : el('span', null, shown); if (shown !== ev.label) label.title = ev.label; if (ev.file) { diff --git a/test/fixtures/story-corpus/bash-script-background-helper.expected.json b/test/fixtures/story-corpus/bash-script-background-helper.expected.json index 7ddc3766..897b5fbf 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -104,7 +104,7 @@ "filesChanged": 0, "added": 0, "removed": 0, - "helpers": 1, + "helpers": 0, "helpersRunning": 0, "panels": [ { @@ -141,7 +141,7 @@ "kind": "complete", "ok": null, "story": "Done in 21 seconds.", - "director": "turn_duration · 21 s · 2 tool calls · 1 helper", + "director": "turn_duration · 21 s · 2 tool calls", "balloon": "I built rename_by_date.sh, the helper agent wrote README.md, and the script passed every test I ran on sample files in samples/. Nothing is committed because this folder isn't a git repository, even though the session context said it was.", "tests": null, "roundsOk": null, diff --git a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json index 10aed630..3d9c8318 100644 --- a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json +++ b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json @@ -117,7 +117,7 @@ "filesChanged": 0, "added": 0, "removed": 0, - "helpers": 1, + "helpers": 0, "helpersRunning": 0, "panels": [ { @@ -144,7 +144,7 @@ "kind": "complete", "ok": null, "story": "Done in 9 seconds.", - "director": "turn_duration · 9 s · 1 tool call · 1 helper", + "director": "turn_duration · 9 s · 1 tool call", "balloon": "The background helper has finished README.md, and I've checked its work.", "tests": null, "roundsOk": null, diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index ce4b3f47..fd6fa0a6 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -151,9 +151,12 @@ describe('story-mode.js overlay', () => { 'save', 'complete', ]); - expect($('.story-panel[data-kind="mission"] .story-balloon')!.textContent).toBe( + expect($('.story-panel[data-kind="mission"] .story-balloon-text')!.textContent).toBe( 'The checkout button does nothing on phones. Can you fix it?' ); + // Who speaks, for a screen reader (visually hidden; the art shows it). + expect($('.story-panel[data-kind="mission"] .story-balloon .story-vh')!.textContent).toBe('You say: '); + expect($('.story-panel[data-kind="mission"]')!.getAttribute('aria-label')).toBe('Panel 1 of 7'); expect($('.story-panel[data-kind="mission"] .story-balloon')!.className).toContain('story-balloon--you'); expect($('.story-panel[data-kind="investigate"] .story-cap-story')!.textContent).toBe( 'Claude reads 2 files to find its way around.' @@ -535,7 +538,8 @@ describe('story-mode.js overlay', () => { await settle(); const balloon = $('.story-panel[data-kind="mission"] .story-balloon')!; expect(balloon.className).toContain('story-balloon--note'); - expect(balloon.textContent).toBe('Agent "Docs" finished'); + expect(balloon.querySelector('.story-balloon-text')!.textContent).toBe('Agent "Docs" finished'); + expect(balloon.querySelector('.story-vh')!.textContent).toBe('Notice: '); }); it("the helper drawer fetches and lists the helper's own steps", async () => { @@ -680,6 +684,8 @@ describe('story-mode.js overlay', () => { await settle(); sizeAndDraw(); expect($('.story-panel[data-kind="complete"] .story-chips')!.textContent).toContain('1 helper, 1 still working'); + // Work still out is not a finished mission. + expect($('.story-panel[data-kind="complete"] .story-splash-title')!.textContent).toBe('To be continued…'); $('.story-panel[data-kind="backup"] .story-hit')!.click(); await settle(); expect($('.story-drawer-sub')!.textContent).toContain('in the background'); diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 64ef6bab..caa0af0a 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -735,6 +735,10 @@ describe('buildStoryTurns', () => { const first = buildStoryPage(turns[0]); expect(first.events[1]).toMatchObject({ intent: 'test', ok: true }); expect(first.events[1].running).toBeUndefined(); + // Its drawer says what happened, not the launch note meant for Claude. + expect(first.events[1].output).toBe( + 'Finished in the background, exit code 0. Its report is the turn it came back in.' + ); const second = buildStoryPage(turns[1]); expect(second.panels.find((p) => p.kind === 'check')).toMatchObject({ caption: { story: 'The tests pass.' }, @@ -751,6 +755,7 @@ describe('buildStoryTurns', () => { const page = buildStoryPage(alone); expect(page.events[1]).toMatchObject({ running: true }); expect(page.events[1].ok).toBeUndefined(); + expect(page.events[1].output).toBe('Started in the background. Its result comes back as a report of its own.'); expect(page.panels.find((p) => p.kind === 'check')!.caption.story).toBe( 'Claude starts the tests in the background.' ); @@ -1033,6 +1038,12 @@ describe('buildStoryPage', () => { expect(whole.hunks[0].atEnd).toBeUndefined(); }); + it('sed -n prints part of a file, like head', () => { + expect(printedFiles("sed -n '5,25p' README.md")).toEqual(['README.md']); + expect(printedFiles('sed -n 10p src/a.py && ls')).toEqual(['src/a.py']); + expect(printedFiles("sed -i 's/a/b/' a.py")).toEqual([]); + }); + it('files a command prints are on its event, resolved against its cwd, whatever it does', () => { const lines = [ prompt('rewrite and test'),