fix(story-mode): read right by a screen reader, and the last of review round 6

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: <session>", 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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 13:51:11 +02:00
1 parent 89c222086a
commit 6e45831909
7 files changed
+83 -17

No files matched your search

+8 -2
View File
@@ -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));
+10 -1
View File
@@ -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);
+42 -8
View File
@@ -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) {
@@ -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,
@@ -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,
+8 -2
View File
@@ -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');
+11
View File
@@ -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'),