From df8c81a2a5a1bf10daea31885fdcf970f245c608 Mon Sep 17 00:00:00 2001 From: Bilal Karim <4129613+bilal-karim@users.noreply.github.com> Date: Fri, 22 May 2026 12:37:49 -0400 Subject: [PATCH 1/8] fix(a11y): announce workflow / event status on timeline graph nodes (WCAG 1.4.1) Adds aria-label to the SVG wrapper on each timeline-graph node so screen readers announce the workflow id and status (or event type and classification) instead of bare "button". Previously, terminal statuses on the timeline graph (Completed, Failed, Canceled, TimedOut, Fired, Signaled) were distinguishable only by node color. The color legend lives inside a Tooltip -- keyboard- and AT-reachable since PR #3429, but still requires the user to leave the graph, decode the color, and come back to identify each node. This change makes the graph self-describing. Cross-cutting wins: - Closes the SC 4.1.2 missing-accessible-name defect on the same widgets. - Closes the SC 1.3.1 matrix-row item d deferred to the Robust wave. New i18n keys: - workflows.row-accessible-name = "Workflow {{workflowId}}: {{status}}" - events.row-accessible-name = "Event {{eventType}}: {{classification}}" Co-Authored-By: Claude Opus 4.6 --- .../svg/history-graph-row-visual.svelte | 21 +++++++++++++- .../lines-and-dots/svg/workflow-row.svelte | 29 ++++++++++++++++++- src/lib/i18n/locales/en/events.ts | 1 + src/lib/i18n/locales/en/workflows.ts | 1 + 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte index 5d803eb203..af99648b8e 100644 --- a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte +++ b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte @@ -1,4 +1,5 @@ - + {#if connectLine} import Icon from '$lib/holocene/icon/icon.svelte'; + import { translate } from '$lib/i18n/translate'; import type { WorkflowExecution } from '$lib/types/workflows'; import { isWorkflowDelayed } from '$lib/utilities/delayed-workflows'; @@ -20,9 +21,35 @@ const start = $derived(gutter); const end = $derived(start + length - 2 * gutter); + + const workflowStatusKey = (status: WorkflowExecution['status']) => { + if (!status) return 'unknown'; + if (status === 'TimedOut') return 'timed-out'; + if (status === 'ContinuedAsNew') return 'continued-as-new'; + return status.toLowerCase(); + }; + + const statusLabel = $derived( + workflow.status + ? translate(`workflows.${workflowStatusKey(workflow.status)}`) + : translate('common.unknown'), + ); + + const accessibleName = $derived( + translate('workflows.row-accessible-name', { + workflowId: workflow.id, + status: statusLabel, + }), + ); - + ), re-arrange (<2>), and remove (<3>), {{type}} to personalize the {{title}} Table.', 'all-statuses': 'All Statuses', + 'row-accessible-name': 'Workflow {{workflowId}}: {{status}}', running: 'Running', 'timed-out': 'Timed Out', completed: 'Completed', From b3ceb65545cd4d42d8db54283b81cd42b4db4a34 Mon Sep 17 00:00:00 2001 From: Bilal Karim <4129613+bilal-karim@users.noreply.github.com> Date: Fri, 22 May 2026 12:47:35 -0400 Subject: [PATCH 2/8] fix(types): use typed lookup tables for translation keys CI's check-types caught two TS errors in the timeline-graph aria-label additions: 1. translate(\`workflows.\${workflowStatusKey(status)}\`) -- the helper's return type widened to plain string, which doesn't satisfy the strongly-typed translation-key union. 2. translate(\`events.event-classification.\${classification.toLowerCase()}\`) -- same shape; toLowerCase() returns plain string. 3. event.eventType -- doesn't exist on PendingActivity (a union member of WorkflowEventWithPending). Replaced both dynamic template-literal lookups with `as const` record literals keyed by the literal-union enum values, so TypeScript can narrow to the exact translation-key union. Added an `in` operator narrowing for the eventType access. Co-Authored-By: Claude Opus 4.6 --- .../svg/history-graph-row-visual.svelte | 36 +++++++++++++++++-- .../lines-and-dots/svg/workflow-row.svelte | 22 +++++++----- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte index af99648b8e..4e560c9470 100644 --- a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte +++ b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte @@ -71,15 +71,45 @@ ); const reverseSort = $derived($eventFilterSort === 'descending'); + const CLASSIFICATION_LABEL_KEY = { + Unspecified: 'events.event-classification.unspecified', + Scheduled: 'events.event-classification.scheduled', + Open: 'events.event-classification.open', + New: 'events.event-classification.new', + Started: 'events.event-classification.started', + Initiated: 'events.event-classification.initiated', + Running: 'events.event-classification.running', + Completed: 'events.event-classification.completed', + Fired: 'events.event-classification.fired', + CancelRequested: 'events.event-classification.cancelrequested', + TimedOut: 'events.event-classification.timedout', + Signaled: 'events.event-classification.signaled', + Canceled: 'events.event-classification.canceled', + Failed: 'events.event-classification.failed', + Terminated: 'events.event-classification.terminated', + pending: 'events.event-classification.pending', + Retrying: 'events.event-classification.retrying', + } as const; + const classificationLabel = $derived( - classification - ? translate(`events.event-classification.${classification.toLowerCase()}`) + classification && classification in CLASSIFICATION_LABEL_KEY + ? translate( + CLASSIFICATION_LABEL_KEY[ + classification as keyof typeof CLASSIFICATION_LABEL_KEY + ], + ) + : translate('common.unknown'), + ); + + const eventTypeLabel = $derived( + event && 'eventType' in event + ? event.eventType : translate('common.unknown'), ); const accessibleName = $derived( translate('events.row-accessible-name', { - eventType: event?.eventType ?? translate('common.unknown'), + eventType: eventTypeLabel, classification: classificationLabel, }), ); diff --git a/src/lib/components/lines-and-dots/svg/workflow-row.svelte b/src/lib/components/lines-and-dots/svg/workflow-row.svelte index ca86ab7389..a4b98eb7cc 100644 --- a/src/lib/components/lines-and-dots/svg/workflow-row.svelte +++ b/src/lib/components/lines-and-dots/svg/workflow-row.svelte @@ -22,16 +22,22 @@ const start = $derived(gutter); const end = $derived(start + length - 2 * gutter); - const workflowStatusKey = (status: WorkflowExecution['status']) => { - if (!status) return 'unknown'; - if (status === 'TimedOut') return 'timed-out'; - if (status === 'ContinuedAsNew') return 'continued-as-new'; - return status.toLowerCase(); - }; + const STATUS_LABEL_KEY = { + Running: 'workflows.running', + TimedOut: 'workflows.timed-out', + Completed: 'workflows.completed', + Failed: 'workflows.failed', + ContinuedAsNew: 'workflows.continued-as-new', + Canceled: 'workflows.canceled', + Terminated: 'workflows.terminated', + Paused: 'workflows.paused', + } as const; const statusLabel = $derived( - workflow.status - ? translate(`workflows.${workflowStatusKey(workflow.status)}`) + workflow.status && workflow.status in STATUS_LABEL_KEY + ? translate( + STATUS_LABEL_KEY[workflow.status as keyof typeof STATUS_LABEL_KEY], + ) : translate('common.unknown'), ); From 2e380953b2b415f74b0899b2a3e132250347f3ba Mon Sep 17 00:00:00 2001 From: Bilal Karim <4129613+bilal-karim@users.noreply.github.com> Date: Fri, 22 May 2026 12:50:48 -0400 Subject: [PATCH 3/8] chore: remove unused Retrying key from classification lookup EventClassification union doesn't include 'Retrying' -- only the i18n key exists. The lookup entry was unreachable. Co-Authored-By: Claude Opus 4.6 --- .../lines-and-dots/svg/history-graph-row-visual.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte index 4e560c9470..d9ed761d32 100644 --- a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte +++ b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte @@ -88,7 +88,6 @@ Failed: 'events.event-classification.failed', Terminated: 'events.event-classification.terminated', pending: 'events.event-classification.pending', - Retrying: 'events.event-classification.retrying', } as const; const classificationLabel = $derived( From dc05e9a6eed694c2d6f86b3f2e53363c74a1ee6e Mon Sep 17 00:00:00 2001 From: Bilal Karim <4129613+bilal-karim@users.noreply.github.com> Date: Fri, 22 May 2026 12:56:17 -0400 Subject: [PATCH 4/8] fix(a11y): announce retrying state on pending-activity timeline events Previously the aria-label for a pending activity always said "Pending" regardless of attempt count. The timeline already communicates retry state visually (line styling), in the legend ("Retry" entry), and in the group-details row text -- but the per-event aria-label missed it. Check group.pendingActivity.attempt > 1 (matching the existing group-details-row.svelte logic) and surface "Retrying" in the aria-label when applicable. The events.event-classification.retrying translation key already existed for this purpose. Co-Authored-By: Claude Opus 4.6 --- .../svg/history-graph-row-visual.svelte | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte index d9ed761d32..a0c06f979b 100644 --- a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte +++ b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte @@ -88,13 +88,22 @@ Failed: 'events.event-classification.failed', Terminated: 'events.event-classification.terminated', pending: 'events.event-classification.pending', + retrying: 'events.event-classification.retrying', } as const; + const isRetrying = $derived( + !!group?.pendingActivity && Number(group.pendingActivity.attempt) > 1, + ); + + const labelClassification = $derived( + isRetrying ? 'retrying' : classification, + ); + const classificationLabel = $derived( - classification && classification in CLASSIFICATION_LABEL_KEY + labelClassification && labelClassification in CLASSIFICATION_LABEL_KEY ? translate( CLASSIFICATION_LABEL_KEY[ - classification as keyof typeof CLASSIFICATION_LABEL_KEY + labelClassification as keyof typeof CLASSIFICATION_LABEL_KEY ], ) : translate('common.unknown'), From 00b90f6dc7ab9e04cab059f5f2348191fe1b790b Mon Sep 17 00:00:00 2001 From: Ardie Wen Date: Wed, 3 Jun 2026 15:33:25 -0400 Subject: [PATCH 5/8] refactor(a11y): extract shared getStatusLabel helper The status/classification -> translated-label mapping existed inline in workflow-status.svelte and was duplicated by the two new lookup tables added for the timeline-graph aria-labels (workflow-row, history-graph-row-visual). Extract the canonical map into $lib/utilities/get-status-label and reuse it in all three call sites so there is a single source of truth. No behavior change: the helper map is a verbatim copy of the canonical Record; the history graph normalizes its lowercase 'pending'/'retrying' values to the PascalCase Status union for lookup while leaving `classification` untouched for styling. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../svg/history-graph-row-visual.svelte | 39 ++++--------------- .../lines-and-dots/svg/workflow-row.svelte | 22 +---------- src/lib/components/workflow-status.svelte | 35 +---------------- src/lib/utilities/get-status-label.ts | 39 +++++++++++++++++++ 4 files changed, 51 insertions(+), 84 deletions(-) create mode 100644 src/lib/utilities/get-status-label.ts diff --git a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte index a0c06f979b..d2b65c56cf 100644 --- a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte +++ b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte @@ -10,6 +10,7 @@ EventTypeCategory, WorkflowEventWithPending, } from '$lib/types/events'; + import { getStatusLabel } from '$lib/utilities/get-status-label'; import { isPendingActivity, isPendingNexusOperation, @@ -71,43 +72,19 @@ ); const reverseSort = $derived($eventFilterSort === 'descending'); - const CLASSIFICATION_LABEL_KEY = { - Unspecified: 'events.event-classification.unspecified', - Scheduled: 'events.event-classification.scheduled', - Open: 'events.event-classification.open', - New: 'events.event-classification.new', - Started: 'events.event-classification.started', - Initiated: 'events.event-classification.initiated', - Running: 'events.event-classification.running', - Completed: 'events.event-classification.completed', - Fired: 'events.event-classification.fired', - CancelRequested: 'events.event-classification.cancelrequested', - TimedOut: 'events.event-classification.timedout', - Signaled: 'events.event-classification.signaled', - Canceled: 'events.event-classification.canceled', - Failed: 'events.event-classification.failed', - Terminated: 'events.event-classification.terminated', - pending: 'events.event-classification.pending', - retrying: 'events.event-classification.retrying', - } as const; - const isRetrying = $derived( !!group?.pendingActivity && Number(group.pendingActivity.attempt) > 1, ); - const labelClassification = $derived( - isRetrying ? 'retrying' : classification, + const statusForLabel = $derived( + isRetrying + ? 'Retrying' + : classification === 'pending' + ? 'Pending' + : classification, ); - const classificationLabel = $derived( - labelClassification && labelClassification in CLASSIFICATION_LABEL_KEY - ? translate( - CLASSIFICATION_LABEL_KEY[ - labelClassification as keyof typeof CLASSIFICATION_LABEL_KEY - ], - ) - : translate('common.unknown'), - ); + const classificationLabel = $derived(getStatusLabel(statusForLabel)); const eventTypeLabel = $derived( event && 'eventType' in event diff --git a/src/lib/components/lines-and-dots/svg/workflow-row.svelte b/src/lib/components/lines-and-dots/svg/workflow-row.svelte index a4b98eb7cc..ac1d0b617b 100644 --- a/src/lib/components/lines-and-dots/svg/workflow-row.svelte +++ b/src/lib/components/lines-and-dots/svg/workflow-row.svelte @@ -3,6 +3,7 @@ import { translate } from '$lib/i18n/translate'; import type { WorkflowExecution } from '$lib/types/workflows'; import { isWorkflowDelayed } from '$lib/utilities/delayed-workflows'; + import { getStatusLabel } from '$lib/utilities/get-status-label'; import { TimelineConfig } from '../constants'; @@ -22,29 +23,10 @@ const start = $derived(gutter); const end = $derived(start + length - 2 * gutter); - const STATUS_LABEL_KEY = { - Running: 'workflows.running', - TimedOut: 'workflows.timed-out', - Completed: 'workflows.completed', - Failed: 'workflows.failed', - ContinuedAsNew: 'workflows.continued-as-new', - Canceled: 'workflows.canceled', - Terminated: 'workflows.terminated', - Paused: 'workflows.paused', - } as const; - - const statusLabel = $derived( - workflow.status && workflow.status in STATUS_LABEL_KEY - ? translate( - STATUS_LABEL_KEY[workflow.status as keyof typeof STATUS_LABEL_KEY], - ) - : translate('common.unknown'), - ); - const accessibleName = $derived( translate('workflows.row-accessible-name', { workflowId: workflow.id, - status: statusLabel, + status: getStatusLabel(workflow.status), }), ); diff --git a/src/lib/components/workflow-status.svelte b/src/lib/components/workflow-status.svelte index 63daa2a0af..2bbd4a5795 100644 --- a/src/lib/components/workflow-status.svelte +++ b/src/lib/components/workflow-status.svelte @@ -8,19 +8,10 @@ import Spinner from '$lib/holocene/icon/svg/spinner.svelte'; import Tooltip from '$lib/holocene/tooltip.svelte'; import { translate } from '$lib/i18n/translate'; - import type { EventClassification } from '$lib/models/event-history/get-event-classification'; - import type { ScheduleStatus } from '$lib/types/schedule'; - import type { WorkflowStatus } from '$lib/types/workflows'; + import { getStatusLabel, type Status } from '$lib/utilities/get-status-label'; import HeartBeat from './heart-beat-indicator.svelte'; - type Status = - | WorkflowStatus - | ScheduleStatus - | EventClassification - | 'Pending' - | 'Retrying'; - interface Props { delay?: number; status?: Status; @@ -45,28 +36,6 @@ 'test-id': testId, }: Props = $props(); - const label: Record = { - Running: translate('workflows.running'), - TimedOut: translate('workflows.timed-out'), - Completed: translate('workflows.completed'), - Failed: translate('workflows.failed'), - ContinuedAsNew: translate('workflows.continued-as-new'), - Canceled: translate('workflows.canceled'), - Terminated: translate('workflows.terminated'), - Paused: translate('workflows.paused'), - Scheduled: translate('events.event-classification.scheduled'), - Started: translate('events.event-classification.started'), - Unspecified: translate('events.event-classification.unspecified'), - Open: translate('events.event-classification.open'), - New: translate('events.event-classification.new'), - Initiated: translate('events.event-classification.initiated'), - Fired: translate('events.event-classification.fired'), - CancelRequested: translate('events.event-classification.cancelrequested'), - Signaled: translate('events.event-classification.signaled'), - Pending: translate('events.event-classification.pending'), - Retrying: translate('events.event-classification.retrying'), - }; - const workflowStatus = cva( [ 'flex items-center rounded-sm px-1 py-0.5 h-5 whitespace-nowrap text-black gap-1 font-medium', @@ -135,7 +104,7 @@ {count.toLocaleString()} {/if} - {label[status]} + {getStatusLabel(status)} {#if status === 'Running' && !delayed && !taskFailure} {/if} diff --git a/src/lib/utilities/get-status-label.ts b/src/lib/utilities/get-status-label.ts new file mode 100644 index 0000000000..97ccdce758 --- /dev/null +++ b/src/lib/utilities/get-status-label.ts @@ -0,0 +1,39 @@ +import type { I18nKey } from '$lib/i18n'; +import { translate } from '$lib/i18n/translate'; +import type { EventClassification } from '$lib/models/event-history/get-event-classification'; +import type { ScheduleStatus } from '$lib/types/schedule'; +import type { WorkflowStatus } from '$lib/types/workflows'; + +export type Status = + | WorkflowStatus + | ScheduleStatus + | EventClassification + | 'Pending' + | 'Retrying'; + +const statusLabelKeys: Record = { + Running: 'workflows.running', + TimedOut: 'workflows.timed-out', + Completed: 'workflows.completed', + Failed: 'workflows.failed', + ContinuedAsNew: 'workflows.continued-as-new', + Canceled: 'workflows.canceled', + Terminated: 'workflows.terminated', + Paused: 'workflows.paused', + Scheduled: 'events.event-classification.scheduled', + Started: 'events.event-classification.started', + Unspecified: 'events.event-classification.unspecified', + Open: 'events.event-classification.open', + New: 'events.event-classification.new', + Initiated: 'events.event-classification.initiated', + Fired: 'events.event-classification.fired', + CancelRequested: 'events.event-classification.cancelrequested', + Signaled: 'events.event-classification.signaled', + Pending: 'events.event-classification.pending', + Retrying: 'events.event-classification.retrying', +}; + +export const getStatusLabel = (status: Status | undefined): string => + status && status in statusLabelKeys + ? translate(statusLabelKeys[status]) + : translate('common.unknown'); From e0991bd747a64138c918330349108814cd368278 Mon Sep 17 00:00:00 2001 From: Ardie Wen Date: Wed, 3 Jun 2026 15:36:18 -0400 Subject: [PATCH 6/8] fix(a11y): announce status on timeline-graph row nodes (WCAG 1.4.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit timeline-graph-row's contained only SVG primitives and (whose accessible-name contribution is unreliable across screen readers), so it announced as bare "button" — the same defect this PR fixes on workflow-row and history-graph-row-visual. Add an aria-label via the shared getStatusLabel helper. group-details-row's is intentionally left for a separate fix: it wraps foreignObject HTML (already name-contributing) and nested interactive elements, so it needs a nested-interactive restructure rather than an aria-label that would mask its content. A11y-Audit-Ref: 1.4.1-timeline-graph-status Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lines-and-dots/svg/timeline-graph-row.svelte | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte index 625b3c9ce6..c20b71ef8b 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte @@ -15,6 +15,7 @@ } from '$lib/utilities/decode-local-activity'; import { getMillisecondDuration } from '$lib/utilities/format-time'; import type { SummaryAttribute } from '$lib/utilities/get-single-attribute-for-event'; + import { getStatusLabel } from '$lib/utilities/get-status-label'; import { isActivityTaskScheduledEvent, isActivityTaskStartedEvent, @@ -54,6 +55,15 @@ const timelineWidth = $derived(canvasWidth - 2 * gutter); const pendingActivity = $derived(group?.pendingActivity); + + const accessibleName = $derived( + translate('events.row-accessible-name', { + eventType: group.displayName, + classification: getStatusLabel( + group.finalClassification || group.classification, + ), + }), + ); const pauseTime = $derived( pendingActivity && pendingActivity.pauseInfo?.pauseTime, ); @@ -197,6 +207,7 @@ Date: Wed, 3 Jun 2026 15:43:44 -0400 Subject: [PATCH 7/8] test(a11y): cover graph node accessible names + getStatusLabel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unit test for the shared getStatusLabel helper (workflow statuses, event classifications, pending/retrying, and the Unknown fallback). - Integration test asserting the timeline graph's SVG nodes expose accessible names: the workflow node ("Workflow : Running") and event nodes ("Event LongActivity: Scheduled", "Event customSignal: Signaled"). Verified red — the assertions fail if the aria-label regresses. history-graph-row-visual shares the same getStatusLabel + events.row-accessible-name mechanism (covered by the unit test and the identical timeline-graph-row path); its /history graph view did not render nodes deterministically in the integration harness. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/utilities/get-status-label.test.ts | 33 ++++++++++++++++++ .../timeline-graph-accessible-names.spec.ts | 34 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/lib/utilities/get-status-label.test.ts create mode 100644 tests/integration/timeline-graph-accessible-names.spec.ts diff --git a/src/lib/utilities/get-status-label.test.ts b/src/lib/utilities/get-status-label.test.ts new file mode 100644 index 0000000000..bde59dc2a5 --- /dev/null +++ b/src/lib/utilities/get-status-label.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { getStatusLabel, type Status } from './get-status-label'; + +describe('getStatusLabel', () => { + it('translates workflow statuses', () => { + expect(getStatusLabel('Running')).toBe('Running'); + expect(getStatusLabel('TimedOut')).toBe('Timed Out'); + expect(getStatusLabel('Completed')).toBe('Completed'); + expect(getStatusLabel('Failed')).toBe('Failed'); + expect(getStatusLabel('ContinuedAsNew')).toBe('Continued as New'); + expect(getStatusLabel('Canceled')).toBe('Canceled'); + expect(getStatusLabel('Terminated')).toBe('Terminated'); + expect(getStatusLabel('Paused')).toBe('Paused'); + }); + + it('translates event classifications', () => { + expect(getStatusLabel('Scheduled')).toBe('Scheduled'); + expect(getStatusLabel('CancelRequested')).toBe('Cancel Requested'); + expect(getStatusLabel('Signaled')).toBe('Signaled'); + expect(getStatusLabel('Fired')).toBe('Fired'); + }); + + it('translates pending and retrying states', () => { + expect(getStatusLabel('Pending')).toBe('Pending'); + expect(getStatusLabel('Retrying')).toBe('Retrying'); + }); + + it('falls back to "Unknown" for undefined or unmapped values', () => { + expect(getStatusLabel(undefined)).toBe('Unknown'); + expect(getStatusLabel('NotAStatus' as Status)).toBe('Unknown'); + }); +}); diff --git a/tests/integration/timeline-graph-accessible-names.spec.ts b/tests/integration/timeline-graph-accessible-names.spec.ts new file mode 100644 index 0000000000..ff11d6c62d --- /dev/null +++ b/tests/integration/timeline-graph-accessible-names.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; + +import { mockWorkflowApis } from '~/test-utilities/mock-apis'; +import { mockWorkflow } from '~/test-utilities/mocks/workflow'; + +const { workflowId, runId } = mockWorkflow.workflowExecutionInfo.execution; +const timelineUrl = `/namespaces/default/workflows/${workflowId}/${runId}/timeline`; + +// The timeline graph renders SVG `` nodes (workflow-row and +// timeline-graph-row). Without an aria-label these announce as a bare "button" +// to screen readers; these assertions fail if that label regresses. +test.describe('Timeline graph node accessible names', () => { + test.beforeEach(async ({ page }) => { + await mockWorkflowApis(page); + await page.goto(timelineUrl); + }); + + test('workflow node announces its id and status', async ({ page }) => { + await expect( + page.getByRole('button', { name: `Workflow ${workflowId}: Running` }), + ).toBeVisible(); + }); + + test('event nodes announce their type and classification', async ({ + page, + }) => { + await expect( + page.getByRole('button', { name: 'Event LongActivity: Scheduled' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Event customSignal: Signaled' }), + ).toBeVisible(); + }); +}); From d5c7186d83106bbbde3f786c5b6de0745a783f1a Mon Sep 17 00:00:00 2001 From: Ardie Wen Date: Wed, 3 Jun 2026 15:57:27 -0400 Subject: [PATCH 8/8] refactor(a11y): split status label helper by domain Avoid collisions between workflow statuses and event classifications that share a name (Running, Completed, Failed, Canceled, Terminated, TimedOut). The previous single combined map silently merged them and routed event nodes through the workflows.* namespace, so a future divergence between workflows.completed and events.event-classification.completed would have mislabeled event nodes. Now expose two domain-scoped resolvers, each backed by an exhaustive Record over its own union (so a new status in either domain forces a label): - getWorkflowStatusLabel -> workflows.* (workflow-row) - getEventClassificationLabel -> events.event-classification.* (history-graph-row-visual, timeline-graph-row) getStatusLabel remains as the polymorphic resolver for WorkflowStatus.svelte, which renders one badge for any status type and cannot know the domain from the value; it prefers the workflow label on shared names, preserving that component's existing behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../svg/history-graph-row-visual.svelte | 6 +- .../svg/timeline-graph-row.svelte | 4 +- .../lines-and-dots/svg/workflow-row.svelte | 4 +- src/lib/utilities/get-status-label.test.ts | 64 +++++++++++++------ src/lib/utilities/get-status-label.ts | 63 ++++++++++++++++-- 5 files changed, 110 insertions(+), 31 deletions(-) diff --git a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte index d2b65c56cf..5cb29ca80a 100644 --- a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte +++ b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte @@ -10,7 +10,7 @@ EventTypeCategory, WorkflowEventWithPending, } from '$lib/types/events'; - import { getStatusLabel } from '$lib/utilities/get-status-label'; + import { getEventClassificationLabel } from '$lib/utilities/get-status-label'; import { isPendingActivity, isPendingNexusOperation, @@ -84,7 +84,9 @@ : classification, ); - const classificationLabel = $derived(getStatusLabel(statusForLabel)); + const classificationLabel = $derived( + getEventClassificationLabel(statusForLabel), + ); const eventTypeLabel = $derived( event && 'eventType' in event diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte index c20b71ef8b..6af68644ad 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte +++ b/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte @@ -15,7 +15,7 @@ } from '$lib/utilities/decode-local-activity'; import { getMillisecondDuration } from '$lib/utilities/format-time'; import type { SummaryAttribute } from '$lib/utilities/get-single-attribute-for-event'; - import { getStatusLabel } from '$lib/utilities/get-status-label'; + import { getEventClassificationLabel } from '$lib/utilities/get-status-label'; import { isActivityTaskScheduledEvent, isActivityTaskStartedEvent, @@ -59,7 +59,7 @@ const accessibleName = $derived( translate('events.row-accessible-name', { eventType: group.displayName, - classification: getStatusLabel( + classification: getEventClassificationLabel( group.finalClassification || group.classification, ), }), diff --git a/src/lib/components/lines-and-dots/svg/workflow-row.svelte b/src/lib/components/lines-and-dots/svg/workflow-row.svelte index ac1d0b617b..26d2e68be0 100644 --- a/src/lib/components/lines-and-dots/svg/workflow-row.svelte +++ b/src/lib/components/lines-and-dots/svg/workflow-row.svelte @@ -3,7 +3,7 @@ import { translate } from '$lib/i18n/translate'; import type { WorkflowExecution } from '$lib/types/workflows'; import { isWorkflowDelayed } from '$lib/utilities/delayed-workflows'; - import { getStatusLabel } from '$lib/utilities/get-status-label'; + import { getWorkflowStatusLabel } from '$lib/utilities/get-status-label'; import { TimelineConfig } from '../constants'; @@ -26,7 +26,7 @@ const accessibleName = $derived( translate('workflows.row-accessible-name', { workflowId: workflow.id, - status: getStatusLabel(workflow.status), + status: getWorkflowStatusLabel(workflow.status), }), ); diff --git a/src/lib/utilities/get-status-label.test.ts b/src/lib/utilities/get-status-label.test.ts index bde59dc2a5..31c09024bd 100644 --- a/src/lib/utilities/get-status-label.test.ts +++ b/src/lib/utilities/get-status-label.test.ts @@ -1,33 +1,59 @@ import { describe, expect, it } from 'vitest'; -import { getStatusLabel, type Status } from './get-status-label'; +import { + getEventClassificationLabel, + getStatusLabel, + getWorkflowStatusLabel, + type Status, +} from './get-status-label'; -describe('getStatusLabel', () => { - it('translates workflow statuses', () => { - expect(getStatusLabel('Running')).toBe('Running'); - expect(getStatusLabel('TimedOut')).toBe('Timed Out'); - expect(getStatusLabel('Completed')).toBe('Completed'); - expect(getStatusLabel('Failed')).toBe('Failed'); - expect(getStatusLabel('ContinuedAsNew')).toBe('Continued as New'); - expect(getStatusLabel('Canceled')).toBe('Canceled'); - expect(getStatusLabel('Terminated')).toBe('Terminated'); - expect(getStatusLabel('Paused')).toBe('Paused'); +describe('getWorkflowStatusLabel', () => { + it('translates workflow / schedule statuses', () => { + expect(getWorkflowStatusLabel('Running')).toBe('Running'); + expect(getWorkflowStatusLabel('TimedOut')).toBe('Timed Out'); + expect(getWorkflowStatusLabel('ContinuedAsNew')).toBe('Continued as New'); + expect(getWorkflowStatusLabel('Paused')).toBe('Paused'); }); - it('translates event classifications', () => { - expect(getStatusLabel('Scheduled')).toBe('Scheduled'); - expect(getStatusLabel('CancelRequested')).toBe('Cancel Requested'); - expect(getStatusLabel('Signaled')).toBe('Signaled'); - expect(getStatusLabel('Fired')).toBe('Fired'); + it('falls back to "Unknown" for null / undefined / event-only names', () => { + expect(getWorkflowStatusLabel(undefined)).toBe('Unknown'); + expect(getWorkflowStatusLabel(null)).toBe('Unknown'); + // Event-only classification is not part of the workflow domain. + expect(getWorkflowStatusLabel('Signaled' as never)).toBe('Unknown'); + }); +}); + +describe('getEventClassificationLabel', () => { + it('translates event classifications, pending and retrying', () => { + expect(getEventClassificationLabel('Scheduled')).toBe('Scheduled'); + expect(getEventClassificationLabel('CancelRequested')).toBe( + 'Cancel Requested', + ); + expect(getEventClassificationLabel('Pending')).toBe('Pending'); + expect(getEventClassificationLabel('Retrying')).toBe('Retrying'); + }); + + it('resolves overlapping names via the event namespace', () => { + // Same English today, but routed through events.* — not workflows.*. + expect(getEventClassificationLabel('Completed')).toBe('Completed'); + expect(getEventClassificationLabel('Failed')).toBe('Failed'); + }); + + it('falls back to "Unknown" for undefined / unmapped values', () => { + expect(getEventClassificationLabel(undefined)).toBe('Unknown'); }); +}); - it('translates pending and retrying states', () => { +describe('getStatusLabel (combined, workflow-domain precedence)', () => { + it('labels both workflow statuses and event classifications', () => { + expect(getStatusLabel('Completed')).toBe('Completed'); + expect(getStatusLabel('Signaled')).toBe('Signaled'); expect(getStatusLabel('Pending')).toBe('Pending'); - expect(getStatusLabel('Retrying')).toBe('Retrying'); }); - it('falls back to "Unknown" for undefined or unmapped values', () => { + it('falls back to "Unknown" for null / undefined / unmapped values', () => { expect(getStatusLabel(undefined)).toBe('Unknown'); + expect(getStatusLabel(null)).toBe('Unknown'); expect(getStatusLabel('NotAStatus' as Status)).toBe('Unknown'); }); }); diff --git a/src/lib/utilities/get-status-label.ts b/src/lib/utilities/get-status-label.ts index 97ccdce758..9dd13f8d5a 100644 --- a/src/lib/utilities/get-status-label.ts +++ b/src/lib/utilities/get-status-label.ts @@ -11,7 +11,11 @@ export type Status = | 'Pending' | 'Retrying'; -const statusLabelKeys: Record = { +type WorkflowDomainStatus = NonNullable; +type EventDomainStatus = EventClassification | 'Pending' | 'Retrying'; + +// Workflow / schedule statuses resolve to the workflows.* namespace. +const workflowStatusLabelKeys: Record = { Running: 'workflows.running', TimedOut: 'workflows.timed-out', Completed: 'workflows.completed', @@ -20,20 +24,67 @@ const statusLabelKeys: Record = { Canceled: 'workflows.canceled', Terminated: 'workflows.terminated', Paused: 'workflows.paused', - Scheduled: 'events.event-classification.scheduled', - Started: 'events.event-classification.started', +}; + +// Event classifications resolve to the events.event-classification.* namespace, +// including names (Running, Completed, …) that also exist as workflow statuses. +const eventClassificationLabelKeys: Record = { Unspecified: 'events.event-classification.unspecified', + Scheduled: 'events.event-classification.scheduled', Open: 'events.event-classification.open', New: 'events.event-classification.new', + Started: 'events.event-classification.started', Initiated: 'events.event-classification.initiated', + Running: 'events.event-classification.running', + Completed: 'events.event-classification.completed', Fired: 'events.event-classification.fired', CancelRequested: 'events.event-classification.cancelrequested', + TimedOut: 'events.event-classification.timedout', Signaled: 'events.event-classification.signaled', + Canceled: 'events.event-classification.canceled', + Failed: 'events.event-classification.failed', + Terminated: 'events.event-classification.terminated', Pending: 'events.event-classification.pending', Retrying: 'events.event-classification.retrying', }; -export const getStatusLabel = (status: Status | undefined): string => - status && status in statusLabelKeys - ? translate(statusLabelKeys[status]) +const isWorkflowStatus = (status: string): status is WorkflowDomainStatus => + status in workflowStatusLabelKeys; + +const isEventClassification = (status: string): status is EventDomainStatus => + status in eventClassificationLabelKeys; + +/** Label a workflow or schedule status (workflows.* namespace). */ +export const getWorkflowStatusLabel = ( + status: WorkflowStatus | ScheduleStatus | undefined, +): string => + status && isWorkflowStatus(status) + ? translate(workflowStatusLabelKeys[status]) + : translate('common.unknown'); + +/** Label an event classification (events.event-classification.* namespace). */ +export const getEventClassificationLabel = ( + classification: EventDomainStatus | undefined, +): string => + classification && isEventClassification(classification) + ? translate(eventClassificationLabelKeys[classification]) : translate('common.unknown'); + +/** + * Polymorphic resolver for WorkflowStatus.svelte, which renders one badge for + * any status — a workflow execution status, a schedule status, or an event + * classification — and cannot tell the domain from the value alone. Names + * shared by both domains (Running, Completed, Failed, Canceled, Terminated, + * TimedOut) resolve to the workflow label, preserving the component's + * historical behavior. Domain-specific callers should prefer + * getWorkflowStatusLabel / getEventClassificationLabel instead. + */ +export const getStatusLabel = (status: Status | undefined): string => { + if (status && isWorkflowStatus(status)) { + return translate(workflowStatusLabelKeys[status]); + } + if (status && isEventClassification(status)) { + return translate(eventClassificationLabelKeys[status]); + } + return translate('common.unknown'); +};