From 3358250ad8addedd05c46f7090aa7b7a6f121416 Mon Sep 17 00:00:00 2001 From: FlyBot Dev-3 Date: Tue, 3 Nov 2026 00:08:27 +0000 Subject: [PATCH] fix(workflow): a live lane row keeps its own name and still names its count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row's aria-label replaced the accessible name computed from its content, so a live row read "{name} · {count} running" and dropped the Workflow badge, its Project and its preview; two lanes that legally share a name then read alike. The count now rides in a visually hidden `.wf-sr` span beside the aria-hidden dot, so the name comes from the content again and a read-only window still hears how many runs are live. Fixes #2230 Owner: Dev-3 --- .../2230-live-row-accessible-name.fixed.md | 1 + docs/subsystems/workflow.md | 2 +- plugins/workflow/client.js | 13 ++- plugins/workflow/test/client.test.js | 94 ++++++++++++++++++- 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 changelog.d/2230-live-row-accessible-name.fixed.md diff --git a/changelog.d/2230-live-row-accessible-name.fixed.md b/changelog.d/2230-live-row-accessible-name.fixed.md new file mode 100644 index 000000000..6550d6fe1 --- /dev/null +++ b/changelog.d/2230-live-row-accessible-name.fixed.md @@ -0,0 +1 @@ +A Workflow row with live runs no longer replaces its accessible name with `{name} · {count} running`. The count rides in a visually hidden span beside the `aria-hidden` dot instead, so a reader gets everything the row's content gives — the lane's name, the `Workflow` badge, its Project and its preview — plus how many runs are live. Two lanes that share a name are distinguishable again, and a read-only window still hears the count (#2230). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index a5507d67c..ea468a8a6 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -41,7 +41,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | `plugins/workflow/src/match.js` | Which open card matches a Workflow or a local Bot. | | `plugins/workflow/src/template.js` | Factory delivery/weekly graphs plus `EARLY_END_GUIDANCE` for Claim/Verify/Triage early-end markers; 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. | | `plugins/workflow/src/narrate.js` | Progress lines for a step. A live run quotes the Bot reply when present; otherwise delivery-template nodes may use canned copy. A join line is only for a rank of more than one node. | -| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: one card per live run with its own title, node line, segment progress, status chip, and Pause / Resume / Stop; click a card or its Flow button to focus that run in the drawer flowchart; optional aggregate chip when several run or the queue is non-empty — no message composer), assign dialog (scrollable with sticky footer; it selects the first card once the board loads, keeps `Assign` disabled until a card is selected, and shows a failed Tasks load as an error with a `Retry` instead of the empty-board hint, #1457, #1458), flowchart drawer (collapsed by default, per-node inspector, fitted to the 400px panel so the graph needs no inner horizontal scroll), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. A row with live runs draws the number beside the dot and carries `{name} · {count} running` as its accessible name; an idle row draws no dot, no count and no digit, so its name stays its own text (#2222). The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers `{ok:false}` or does not answer within 10 s — ends on the section's `.wf-err` alert instead of being swallowed, and a success clears the alert. The flowchart's node role sub-label takes its colour from `--bt-ink-2` rather than a hardcoded grey, so it stays readable on every theme's node card (#1495). The drawer's styling reuses the core `--bt-*` tokens and the Bot details drawer's patterns (title, pill tabs, soft pill buttons, bubble-surfaced node cards; accent tints mixed from `--bt-accent`, #1654). The stage lists the tasks the open workflow holds (`heldTasks`, with a `{count} more held tasks` summary when `heldTaskCount` exceeds the cap), each with one **Re-match** action that drops that task's claim and matches it again through `workflow/ingest` with `rematch: true` — one board read to hand over the card's fields and one state read after, never a second polling loop (#1647). The drawer's Edit plane keeps one copy of the row geometry: a graph whose parallel cards still fit is the fitted plane (scaled centres, `parallelFrameStyle` frame, one-fold `wirePath`), and one that cannot is laid out in `flowLayout`'s own rows — its cards are those boxes, its dashed frame is that band's frame, and its wires are the `flowWires` routing around them, so a 3+ way step at a 364px drawer keeps its frame inside the panel and no wire crosses a card (#1693). A two-Bot parallel step the fitted plane would squeeze — the drawer's fit leaves the pair 4px apart, with each card's out-port (5px) and remove button (8px) past its right edge under the neighbour — is drawn at that row's `flowLayout` column width (154px at a 364px drawer) on its fitted centres and wires, keeping the 12px row gap and a plane whose drags still commit world coordinates; `parallelFrameStyle` adds the 20px label band above the cards and a 10px pad below them, so the fitted frame encloses every card it names (#1738). | +| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: one card per live run with its own title, node line, segment progress, status chip, and Pause / Resume / Stop; click a card or its Flow button to focus that run in the drawer flowchart; optional aggregate chip when several run or the queue is non-empty — no message composer), assign dialog (scrollable with sticky footer; it selects the first card once the board loads, keeps `Assign` disabled until a card is selected, and shows a failed Tasks load as an error with a `Retry` instead of the empty-board hint, #1457, #1458), flowchart drawer (collapsed by default, per-node inspector, fitted to the 400px panel so the graph needs no inner horizontal scroll), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. A row with live runs draws the number beside the dot and carries the count, with the word `running`, in a visually hidden `.wf-sr` span — the dot and the digit sit inside an `aria-hidden` wrap, so the number alone would be silent to a reader — and its accessible name therefore stays the name its content gives: the lane's name, the `Workflow` badge, its Project and its preview, plus the count; a row-level `aria-label` replaced all of that and made two lanes that share a name read alike (#2222, #2230). An idle row draws no dot, no count, no digit and no hidden text, so its name stays its own text. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers `{ok:false}` or does not answer within 10 s — ends on the section's `.wf-err` alert instead of being swallowed, and a success clears the alert. The flowchart's node role sub-label takes its colour from `--bt-ink-2` rather than a hardcoded grey, so it stays readable on every theme's node card (#1495). The drawer's styling reuses the core `--bt-*` tokens and the Bot details drawer's patterns (title, pill tabs, soft pill buttons, bubble-surfaced node cards; accent tints mixed from `--bt-accent`, #1654). The stage lists the tasks the open workflow holds (`heldTasks`, with a `{count} more held tasks` summary when `heldTaskCount` exceeds the cap), each with one **Re-match** action that drops that task's claim and matches it again through `workflow/ingest` with `rematch: true` — one board read to hand over the card's fields and one state read after, never a second polling loop (#1647). The drawer's Edit plane keeps one copy of the row geometry: a graph whose parallel cards still fit is the fitted plane (scaled centres, `parallelFrameStyle` frame, one-fold `wirePath`), and one that cannot is laid out in `flowLayout`'s own rows — its cards are those boxes, its dashed frame is that band's frame, and its wires are the `flowWires` routing around them, so a 3+ way step at a 364px drawer keeps its frame inside the panel and no wire crosses a card (#1693). A two-Bot parallel step the fitted plane would squeeze — the drawer's fit leaves the pair 4px apart, with each card's out-port (5px) and remove button (8px) past its right edge under the neighbour — is drawn at that row's `flowLayout` column width (154px at a 364px drawer) on its fitted centres and wires, keeping the 12px row gap and a plane whose drags still commit world coordinates; `parallelFrameStyle` adds the 20px label band above the cards and a 10px pad below them, so the fitted frame encloses every card it names (#1738). | ## Entry points diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 90eed30ee..f762fc73f 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -37,6 +37,7 @@ window.__ModuleLoader__.load({ 'One Bot (as before)': '单个 Bot(原有方式)', 'Concurrent runs': '同时运行', '{count} running · queue {depth}': '{count} 个在运行 · 队列 {depth}', + '{count} running': '{count} 个在运行', '{name} · {count} running': '{name} · {count} 个在运行', Reset: '重置', Flow: '流程图', @@ -2207,9 +2208,6 @@ window.__ModuleLoader__.load({ workflows.length > 0 && shown.length === 0 ? h('div', { className: 'wf-muted' }, tr('No workflows in this Project')) : null, shown.map(item => h('button', { key: item.id, type: 'button', className: `wf-row ${openId === item.id ? 'on' : ''}`, - // A row with live runs says how many: the dot is colour only, and a - // reader who cannot see it still needs the number (P-433). - 'aria-label': (item.activeCount ?? 0) > 0 ? tr('{name} · {count} running', { name: item.name, count: item.activeCount }) : undefined, onClick: () => { setOpenId(item.id) }, onContextMenu: locked ? undefined : (event) => { event.preventDefault() @@ -2224,7 +2222,13 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-t2' }, sidebarPreview(item))), (item.activeCount ?? 0) > 0 ? h('span', { className: 'wf-live-wrap', 'aria-hidden': true }, h('span', { className: 'wf-live' }), - h('span', { className: 'wf-live-n' }, String(item.activeCount))) : null)), + h('span', { className: 'wf-live-n' }, String(item.activeCount))) : null, + // #2230: the count is the only live text a reader gets, so it cannot ride inside + // the `aria-hidden` wrap: a row-level `aria-label` replaced the name the content + // gives (the lane's name, the Workflow badge, its Project, its preview), and two + // lanes may carry the same name. Hidden text keeps the count without taking those + // away — sighted readers still get the dot and the digit alone. + (item.activeCount ?? 0) > 0 ? h('span', { className: 'wf-sr' }, tr('{count} running', { count: item.activeCount })) : null)), locked ? null : h('button', { type: 'button', className: 'wf-row wf-assign-row', onClick: () => { setAssignOpen(true); void loadTasks() } }, h('span', { className: 'wf-ico' }, glyph()), h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, tr('Hand a task to a Workflow')))), @@ -2339,6 +2343,7 @@ window.__ModuleLoader__.load({ .wf-live{width:7px;height:7px;border-radius:50%;background:var(--bt-accent,#4d6bfe);flex:none} .wf-live-wrap{display:flex;align-items:center;gap:4px;flex:none} .wf-live-n{font-size:10px;font-weight:650;line-height:1;color:var(--bt-accent,#4d6bfe)} +.wf-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0} .wf-ico{width:32px;height:32px;border-radius:9px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));display:grid;place-items:center;color:var(--bt-accent,#4d6bfe);flex:none;overflow:hidden;padding:2px;box-sizing:border-box} .wf-mini-flow{display:block;max-width:100%;max-height:100%} .wf-mini-flow-compact{width:100%;height:100%} diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index b60d5b85a..9c7f056d7 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -109,6 +109,20 @@ const findAll = (node, pred, out = []) => { return out } const textOf = node => (typeof node === 'string' ? node : node ? (node.children ?? []).map(textOf).join('') : '') +// #2230: the name a reader gets. An `aria-label` replaces the name computed from the +// content; otherwise the content names the element, with every `aria-hidden` subtree +// skipped — the rule a browser applies, so a test can read the same name a reader hears. +const accessibleName = (node) => { + const label = node?.props?.['aria-label'] + if (typeof label === 'string' && label !== '') return label + const walk = (child) => { + if (typeof child === 'string') return child + if (!child || typeof child !== 'object') return '' + if (child.props?.['aria-hidden'] === true) return '' + return (child.children ?? []).map(walk).join('') + } + return (node?.children ?? []).map(walk).join('') +} const esc = s => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') const htmlOf = (node) => { if (Array.isArray(node)) return node.map(htmlOf).join('') @@ -652,7 +666,7 @@ test('#2222: a live workflow row says how many runs are live', async () => { const count = findAll(view.tree, node => node.props?.className === 'wf-live-n') assert.equal(count.length, 1, 'one count stands beside the live dot') assert.equal(textOf(count[0]), '3', 'the count is the number of live runs') - assert.ok(String(row.props['aria-label'] ?? '').includes('3'), 'the accessible name says how many runs are live') + assert.ok(accessibleName(row).includes('3 running'), 'the accessible name says how many runs are live') row.props.onClick() await settle() @@ -678,7 +692,7 @@ test('#2222: a read-only window still reads how many runs are live', async () => await settle() const row = findAll(view.tree, node => node.type === 'button' && textOf(node).includes(graph.name))[0] assert.ok(row, 'the read-only sidebar still lists the workflow') - assert.ok(String(row.props['aria-label'] ?? '').includes('2'), 'the read-only row still names the live count') + assert.ok(accessibleName(row).includes('2 running'), 'the read-only row still names the live count') assert.equal(textOf(findAll(view.tree, node => node.props?.className === 'wf-live-n')[0]), '2') assert.equal(findAll(view.tree, node => node.type === 'button' && textOf(node) === 'New').length, 0, 'the window is locked') } finally { @@ -708,6 +722,82 @@ test('#2222: a workflow row with nothing live draws no count and no live name', } }) +// #2230 (P-445, regression of #2224): two lanes may legally share a name — `service.js` +// only appends a copy suffix once — so a row-level `aria-label` that replaces the name +// computed from the content made two different lanes read the same words, with neither +// Project nor preview. A live row must keep everything an idle row names, plus the count. +test('#2230: two lanes with one name are told apart by their own Project, and a live row keeps the badge and the preview', async () => { + const alpha = { ...delivery(), id: 'delivery-a', name: 'Delivery', projectId: 'p1', preview: 'Alpha #2214 · step 2/31', activeCount: 1 } + const beta = { ...delivery(), id: 'delivery-b', name: 'Delivery', projectId: 'p2', preview: 'Beta #2219 · step 1/31', activeCount: 1 } + const idle = { ...delivery(), id: 'delivery-c', name: 'Gamma', activeCount: 0, run: null } + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [alpha, beta, idle], templates: [] } + return { ok: true, workflows: [alpha, beta, idle] } + } + const host = async (channel) => { + if (channel === 'projects') return { projects: [{ id: 'p1', name: 'Alpha' }, { id: 'p2', name: 'Beta' }] } + return [] + } + const prev = globalThis.__flybot__ + globalThis.__flybot__ = { host } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + try { + await settle() + const rows = findAll(view.tree, node => node.type === 'button' && String(node.props?.className ?? '').includes('wf-row') && textOf(node).includes('Delivery')) + assert.equal(rows.length, 2, 'both lanes with one name are listed') + const [first, second] = rows.map(accessibleName) + assert.notEqual(first, second, 'two lanes a reader must tell apart do not share one name') + assert.ok(first.includes('Alpha'), 'the first row names its own Project') + assert.ok(second.includes('Beta'), 'the second row names its own Project') + + const idleRow = findAll(view.tree, node => node.type === 'button' && String(node.props?.className ?? '').includes('wf-row') && textOf(node).includes('Gamma'))[0] + const idleName = accessibleName(idleRow) + assert.ok(idleName.includes('Workflow') && idleName.includes('Waiting for a task'), 'an idle row is named by its badge and its preview') + for (const [i, name] of rows.map(accessibleName).entries()) { + assert.ok(name.includes('Workflow'), `live row ${i} keeps the Workflow badge an idle row names`) + assert.ok(name.includes('step'), `live row ${i} keeps the preview an idle row names`) + assert.ok(name.includes('1 running'), `live row ${i} still says how many runs are live`) + } + } finally { + view.unmount() + globalThis.__flybot__ = prev + } +}) + +// #2230: the two reverse proofs. The counter is still reachable — one live row still names +// its count — and a quiet row still carries no `aria-label`, no digit and no hidden text, +// which is the #2224 contract this change must not reopen. +test('#2230: one live row still names its count, and an idle row carries no aria-label and no hidden text', async () => { + const live = async (count) => { + const graph = { ...delivery(), activeCount: count } + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + return view + } + const busy = await live(3) + try { + const row = findAll(busy.tree, node => node.type === 'button' && String(node.props?.className ?? '').includes('wf-row') && textOf(node).includes('发布交付流水线'))[0] + assert.equal(row.props['aria-label'], undefined, 'the row no longer overrides the name computed from its content') + assert.ok(accessibleName(row).includes('3 running'), 'the count is still part of the name') + assert.equal(findAll(busy.tree, node => node.props?.className === 'wf-sr').length, 1, 'one hidden span carries the count') + } finally { + busy.unmount() + } + const quiet = await live(0) + try { + const row = findAll(quiet.tree, node => node.type === 'button' && String(node.props?.className ?? '').includes('wf-row') && textOf(node).includes('发布交付流水线'))[0] + assert.equal(row.props['aria-label'], undefined, 'an idle row keeps its own text as the name') + assert.equal(/\d/.test(accessibleName(row)), false, 'an idle name carries no digit') + assert.equal(findAll(quiet.tree, node => node.props?.className === 'wf-sr').length, 0, 'an idle row draws no hidden text either') + } finally { + quiet.unmount() + } +}) + // #2222 B: opening the list in a read-only window used to write // `workflow/ingest` once per open card -- a write the reader never asked for, // and one the host refuses. The Project filter still fills (its read stays).