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..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
@@ -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';
+ import { getWorkflowStatusLabel } from '$lib/utilities/get-status-label';
import { TimelineConfig } from '../constants';
@@ -20,9 +22,22 @@
const start = $derived(gutter);
const end = $derived(start + length - 2 * gutter);
+
+ const accessibleName = $derived(
+ translate('workflows.row-accessible-name', {
+ workflowId: workflow.id,
+ status: getWorkflowStatusLabel(workflow.status),
+ }),
+ );
-
+
= {
- 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/i18n/locales/en/events.ts b/src/lib/i18n/locales/en/events.ts
index 72230aadc6..66eea86b68 100644
--- a/src/lib/i18n/locales/en/events.ts
+++ b/src/lib/i18n/locales/en/events.ts
@@ -66,6 +66,7 @@ export const Strings = {
'event-history-import-error': 'Could not create event history from JSON',
'event-history-load-error': 'Could not parse JSON',
'event-classification-label': 'Event Classification',
+ 'row-accessible-name': 'Event {{eventType}}: {{classification}}',
'event-classification': {
unspecified: 'Unspecified',
scheduled: 'Scheduled',
diff --git a/src/lib/i18n/locales/en/workflows.ts b/src/lib/i18n/locales/en/workflows.ts
index 8b427a67cd..a12b9a419e 100644
--- a/src/lib/i18n/locales/en/workflows.ts
+++ b/src/lib/i18n/locales/en/workflows.ts
@@ -41,6 +41,7 @@ export const Strings = {
'configure-headers-description':
'Add (<1>1>), re-arrange (<2>2>), and remove (<3>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',
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..31c09024bd
--- /dev/null
+++ b/src/lib/utilities/get-status-label.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ getEventClassificationLabel,
+ getStatusLabel,
+ getWorkflowStatusLabel,
+ type Status,
+} from './get-status-label';
+
+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('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');
+ });
+});
+
+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');
+ });
+
+ 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
new file mode 100644
index 0000000000..9dd13f8d5a
--- /dev/null
+++ b/src/lib/utilities/get-status-label.ts
@@ -0,0 +1,90 @@
+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';
+
+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',
+ Failed: 'workflows.failed',
+ ContinuedAsNew: 'workflows.continued-as-new',
+ Canceled: 'workflows.canceled',
+ Terminated: 'workflows.terminated',
+ Paused: 'workflows.paused',
+};
+
+// 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',
+};
+
+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');
+};
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();
+ });
+});