diff --git a/.changeset/5916-record-path-stage-state-accessible-name.md b/.changeset/5916-record-path-stage-state-accessible-name.md
new file mode 100644
index 0000000000..7b6bf579e1
--- /dev/null
+++ b/.changeset/5916-record-path-stage-state-accessible-name.md
@@ -0,0 +1,22 @@
+---
+'@object-ui/plugin-detail': patch
+'@object-ui/i18n': patch
+---
+
+`record:path` now announces each stage's state, not just its label (WCAG 2.2 SC 1.4.1)
+
+The lifecycle path distinguished travelled, upcoming and lost-terminal stages with
+colour plus a `✓`/`✗` glyph, and both glyphs are `aria-hidden` decoration.
+`aria-current="step"` marked the current stage and nothing else, so a screen-reader
+user heard a run of identically-announced items — and a rejected stage announced
+exactly like an ordinary stage the record had not reached yet.
+
+Each stage now carries an accessible name composing its (already picklist-localized)
+label with its state, from five new `detail.pathStage*` keys translated in all ten
+locale packs. The glyphs stay decorative and the readout's `role="listitem"` /
+`aria-current` semantics are unchanged.
+
+The name is composed into `aria-label` rather than visually-hidden text because
+`listitem` takes its name from the author only: text placed inside a stage computes
+to an empty accessible name, so the visually-hidden shape would have looked right in
+the markup and delivered nothing to the accessibility tree.
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index a1932dd2fe..6a5ab61b94 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -978,6 +978,11 @@ const ar = {
cancelApprovalTooltipUnlocked: "إلغاء طلب الموافقة المعلق",
cancelApprovalFailed: "فشل إلغاء الموافقة",
cancelApprovalUnavailable: "إلغاء الموافقات غير مدعوم من مصدر البيانات هذا",
+ pathStageCompleted: '{{stage}}، مكتملة',
+ pathStageCurrent: '{{stage}}، المرحلة الحالية',
+ pathStageUpcoming: '{{stage}}، قادمة',
+ pathStageLostCurrent: '{{stage}}، خاسرة، المرحلة الحالية',
+ pathStageLostUpcoming: '{{stage}}، خاسرة، لم يتم الوصول إليها',
linkCopied: "تم نسخ الرابط إلى الحافظة",
linkCopyFailed: "فشل نسخ الرابط",
cancel: "إلغاء",
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index bb38c80442..cf15c1a9b6 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -972,6 +972,11 @@ const de = {
cancelApprovalTooltipUnlocked: "Ausstehende Genehmigungsanfrage zurückziehen",
cancelApprovalFailed: "Genehmigung konnte nicht zurückgezogen werden",
cancelApprovalUnavailable: "Das Zurückziehen von Genehmigungen wird bei dieser Datenquelle nicht unterstützt",
+ pathStageCompleted: '{{stage}}, abgeschlossen',
+ pathStageCurrent: '{{stage}}, aktuelle Phase',
+ pathStageUpcoming: '{{stage}}, ausstehend',
+ pathStageLostCurrent: '{{stage}}, verloren, aktuelle Phase',
+ pathStageLostUpcoming: '{{stage}}, verloren, nicht erreicht',
linkCopied: "Link in die Zwischenablage kopiert",
linkCopyFailed: "Link konnte nicht kopiert werden",
cancel: "Abbrechen",
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 8732d7d0da..9fdd43389a 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -943,6 +943,16 @@ const en = {
cancelApprovalTooltipUnlocked: 'Recall the pending approval request',
cancelApprovalFailed: 'Failed to recall approval',
cancelApprovalUnavailable: 'Recalling approvals is not supported on this data source',
+ // objectui#5916 — `record:path` stage state in the ACCESSIBLE NAME.
+ // Each stage is a `role="listitem"`, which is name-from-AUTHOR only, so
+ // visually-hidden text inside it computes to an EMPTY name; these compose
+ // the stage's own (already picklist-localized) label with its state into the
+ // `aria-label`. The ✓/✗ glyphs stay `aria-hidden` decoration.
+ pathStageCompleted: '{{stage}}, completed',
+ pathStageCurrent: '{{stage}}, current stage',
+ pathStageUpcoming: '{{stage}}, upcoming',
+ pathStageLostCurrent: '{{stage}}, closed lost, current stage',
+ pathStageLostUpcoming: '{{stage}}, closed lost, not reached',
linkCopied: 'Link copied to clipboard',
linkCopyFailed: 'Failed to copy link',
cancel: 'Cancel',
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index 47695a1b04..def7e79b33 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -976,6 +976,11 @@ const es = {
cancelApprovalTooltipUnlocked: "Cancelar la solicitud de aprobación pendiente",
cancelApprovalFailed: "No se pudo cancelar la aprobación",
cancelApprovalUnavailable: "La cancelación de aprobaciones no es compatible con esta fuente de datos",
+ pathStageCompleted: '{{stage}}, completada',
+ pathStageCurrent: '{{stage}}, etapa actual',
+ pathStageUpcoming: '{{stage}}, pendiente',
+ pathStageLostCurrent: '{{stage}}, perdida, etapa actual',
+ pathStageLostUpcoming: '{{stage}}, perdida, no alcanzada',
linkCopied: "Enlace copiado al portapapeles",
linkCopyFailed: "No se pudo copiar el enlace",
cancel: "Cancelar",
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index 771e32bf9a..897ab3bef2 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -974,6 +974,11 @@ const fr = {
cancelApprovalTooltipUnlocked: "Annuler la demande d'approbation en attente",
cancelApprovalFailed: "Échec de l'annulation de l'approbation",
cancelApprovalUnavailable: "L'annulation des approbations n'est pas prise en charge par cette source de données",
+ pathStageCompleted: '{{stage}}, terminée',
+ pathStageCurrent: '{{stage}}, étape actuelle',
+ pathStageUpcoming: '{{stage}}, à venir',
+ pathStageLostCurrent: '{{stage}}, perdue, étape actuelle',
+ pathStageLostUpcoming: '{{stage}}, perdue, non atteinte',
linkCopied: "Lien copié dans le presse-papiers",
linkCopyFailed: "Impossible de copier le lien",
cancel: "Annuler",
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 58ad470eb9..54327ba121 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -983,6 +983,11 @@ const ja = {
cancelApprovalTooltipUnlocked: "承認待ちリクエストを取り消す",
cancelApprovalFailed: "承認の取り消しに失敗しました",
cancelApprovalUnavailable: "このデータソースでは承認の取り消しはサポートされていません",
+ pathStageCompleted: '{{stage}}、完了',
+ pathStageCurrent: '{{stage}}、現在のステージ',
+ pathStageUpcoming: '{{stage}}、未着手',
+ pathStageLostCurrent: '{{stage}}、失注、現在のステージ',
+ pathStageLostUpcoming: '{{stage}}、失注、未到達',
linkCopied: "リンクをクリップボードにコピーしました",
linkCopyFailed: "リンクのコピーに失敗しました",
cancel: "キャンセル",
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index fc90ca745d..84132a91f8 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -972,6 +972,11 @@ const ko = {
cancelApprovalTooltipUnlocked: "대기 중인 승인 요청 취소",
cancelApprovalFailed: "승인 취소 실패",
cancelApprovalUnavailable: "이 데이터 소스에서는 승인 취소가 지원되지 않습니다",
+ pathStageCompleted: '{{stage}}, 완료됨',
+ pathStageCurrent: '{{stage}}, 현재 단계',
+ pathStageUpcoming: '{{stage}}, 예정',
+ pathStageLostCurrent: '{{stage}}, 실패, 현재 단계',
+ pathStageLostUpcoming: '{{stage}}, 실패, 도달하지 않음',
linkCopied: "링크가 클립보드에 복사됨",
linkCopyFailed: "링크 복사 실패",
cancel: "취소",
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 304c506d1b..d34faaf086 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -973,6 +973,11 @@ const pt = {
cancelApprovalTooltipUnlocked: "Cancelar a solicitação de aprovação pendente",
cancelApprovalFailed: "Falha ao cancelar aprovação",
cancelApprovalUnavailable: "O cancelamento de aprovações não é suportado por esta fonte de dados",
+ pathStageCompleted: '{{stage}}, concluída',
+ pathStageCurrent: '{{stage}}, etapa atual',
+ pathStageUpcoming: '{{stage}}, pendente',
+ pathStageLostCurrent: '{{stage}}, perdida, etapa atual',
+ pathStageLostUpcoming: '{{stage}}, perdida, não alcançada',
linkCopied: "Link copiado para a área de transferência",
linkCopyFailed: "Falha ao copiar o link",
cancel: "Cancelar",
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index 20a7249fdb..cf3c8facc3 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -991,6 +991,11 @@ const ru = {
cancelApprovalTooltipUnlocked: "Отменить ожидающий запрос на согласование",
cancelApprovalFailed: "Не удалось отменить согласование",
cancelApprovalUnavailable: "Отмена согласований не поддерживается этим источником данных",
+ pathStageCompleted: '{{stage}}, завершён',
+ pathStageCurrent: '{{stage}}, текущий этап',
+ pathStageUpcoming: '{{stage}}, предстоит',
+ pathStageLostCurrent: '{{stage}}, проигран, текущий этап',
+ pathStageLostUpcoming: '{{stage}}, проигран, не достигнут',
linkCopied: "Ссылка скопирована в буфер обмена",
linkCopyFailed: "Не удалось скопировать ссылку",
cancel: "Отмена",
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 6afa03c8a5..5d6f2e9062 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -879,6 +879,11 @@ const zh = {
cancelApprovalTooltipUnlocked: '撤回当前的待审批请求',
cancelApprovalFailed: '撤回审批失败',
cancelApprovalUnavailable: '当前数据源不支持撤回审批',
+ pathStageCompleted: '{{stage}},已完成',
+ pathStageCurrent: '{{stage}},当前阶段',
+ pathStageUpcoming: '{{stage}},未开始',
+ pathStageLostCurrent: '{{stage}},已失败,当前阶段',
+ pathStageLostUpcoming: '{{stage}},已失败,未到达',
linkCopied: '链接已复制到剪贴板',
linkCopyFailed: '复制链接失败',
cancel: '取消',
diff --git a/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.i18n.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.i18n.test.tsx
new file mode 100644
index 0000000000..5035b6b1fe
--- /dev/null
+++ b/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.i18n.test.tsx
@@ -0,0 +1,118 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * ══════════════════════════════════════════════════════════════════════════
+ * The stage-state announcement comes from the LOCALE PACKS (objectui#5916)
+ * ══════════════════════════════════════════════════════════════════════════
+ *
+ * This is the half of the card that made it its own card rather than a one-line
+ * addition: the state a stage announces is user-facing copy, so it has to be a
+ * translated key — NOT the hardcoded English literal the component still uses
+ * for its two container labels (`'Record path'`, `'Alternative terminal
+ * stages'`, both untouched here and filed separately).
+ *
+ * `record-path.stageStateAccessibleName.test.tsx` covers the SAME assertions on
+ * the provider-less path, where `useDetailTranslation` serves
+ * `DETAIL_DEFAULT_TRANSLATIONS`. The two paths are split across two files, not
+ * two describes: `createI18n` registers its instance as react-i18next's
+ * module-global default and the registration survives `cleanup()`, so a
+ * provider-less render in this file would silently resolve against whichever
+ * locale a previous case mounted. plugin-kanban's overlay-title pair splits for
+ * exactly this reason and records it.
+ *
+ * Both paths must agree byte for byte on `en` — a map row that disagrees with
+ * the pack labels one control two ways depending on whether a provider is
+ * mounted (objectui#4401). `defaults-maps-mirror-en-pack.test.tsx` owns that
+ * invariant globally; the `en` case below is this component's local instance of
+ * it, and the two would fail together.
+ *
+ * ── What is asserted, and what is deliberately not ────────────────────────
+ *
+ * The STATE half of each name. The stage LABEL half is picklist data localized
+ * upstream by `translateOptions`, and with no object metadata registered it
+ * falls back to the schema's own labels — so the fixture's labels are asserted
+ * as-is and this file makes no claim about picklist translation, which is not
+ * its subject.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import '@testing-library/jest-dom';
+import { render, cleanup, within, type RenderResult } from '@testing-library/react';
+import { I18nProvider } from '@object-ui/i18n';
+import { RecordContextProvider } from '@object-ui/react';
+import { RecordPathRenderer } from '../record-path';
+
+const STAGES = [
+ { value: 'draft', label: '草稿' },
+ { value: 'in_review', label: '审核中' },
+ { value: 'submitted', label: '已提交' },
+ { value: 'declined', label: '已拒绝', terminal: 'lost' as const },
+];
+
+function mountIn(language: string, status: string): RenderResult {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+/** The desktop row; both rows carry identical names, and the split is covered next door. */
+const firstRow = (r: RenderResult): HTMLElement =>
+ (Array.from(r.container.querySelectorAll('[role="list"]')) as HTMLElement[])[0];
+
+const stagesOf = (row: HTMLElement): HTMLElement[] => within(row).getAllByRole('listitem');
+
+afterEach(() => cleanup());
+
+describe('record:path stage state speaks the session locale (objectui#5916)', () => {
+ it('en announces the same bytes the provider-less defaults map does', () => {
+ // The #4401 invariant, locally: pack and map must not fork.
+ const row = firstRow(mountIn('en', 'submitted'));
+ expect(stagesOf(row)[0]).toHaveAccessibleName('草稿, completed');
+ expect(stagesOf(row)[2]).toHaveAccessibleName('已提交, current stage');
+ expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝, closed lost, not reached');
+ });
+
+ it('zh announces the state in Chinese, with no English left in the name', () => {
+ const row = firstRow(mountIn('zh', 'submitted'));
+ expect(stagesOf(row)[0]).toHaveAccessibleName('草稿,已完成');
+ expect(stagesOf(row)[2]).toHaveAccessibleName('已提交,当前阶段');
+ expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝,已失败,未到达');
+ // The point of the card: a zh session must not hear English state words.
+ for (const stage of stagesOf(row)) {
+ expect(stage).not.toHaveAccessibleName(/completed|current stage|upcoming|closed lost/);
+ }
+ });
+
+ it('de announces the state in German', () => {
+ const row = firstRow(mountIn('de', 'submitted'));
+ expect(stagesOf(row)[0]).toHaveAccessibleName('草稿, abgeschlossen');
+ expect(stagesOf(row)[2]).toHaveAccessibleName('已提交, aktuelle Phase');
+ expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝, verloren, nicht erreicht');
+ });
+
+ it('the record on a lost terminal announces it in the session locale too', () => {
+ const row = firstRow(mountIn('zh', 'declined'));
+ expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝,已失败,当前阶段');
+ });
+
+ it('the three locales do not all render the same string — the key is really consulted', () => {
+ // Non-vacuity: if `t()` were bypassed (or every pack carried the English),
+ // these three would coincide and every case above would still pass.
+ const seen = new Set();
+ for (const lang of ['en', 'zh', 'de']) {
+ const row = firstRow(mountIn(lang, 'submitted'));
+ seen.add(stagesOf(row)[2].getAttribute('aria-label') ?? '');
+ cleanup();
+ }
+ expect(seen.size).toBe(3);
+ });
+});
diff --git a/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.test.tsx
new file mode 100644
index 0000000000..507f20ff20
--- /dev/null
+++ b/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.test.tsx
@@ -0,0 +1,268 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * ══════════════════════════════════════════════════════════════════════════
+ * A `record:path` stage announces its STATE, not just its label (objectui#5916)
+ * ══════════════════════════════════════════════════════════════════════════
+ *
+ * The defect: travelled / upcoming / lost reached a sighted user through colour
+ * (emerald / muted / destructive) and a glyph, and BOTH glyphs are `aria-hidden`
+ * decoration. `aria-current="step"` marked the current stage and nothing else, so
+ * a screen-reader user got a list of identically-announced items and could not
+ * tell 草稿 (behind the record) from 已接受 (ahead of it). A `lost` terminal was
+ * the sharpest case: 已拒绝 announced exactly like an ordinary unreached stage.
+ * WCAG 2.2 SC 1.4.1 *Use of Color*.
+ *
+ * ── Why `aria-label` and not visually-hidden text ─────────────────────────
+ *
+ * The card offered both shapes. The choice is a MEASUREMENT, not a preference:
+ * `listitem` is not a name-from-contents role, so text placed inside a stage —
+ * `sr-only` or otherwise — leaves the accessible name EMPTY. Measured on this
+ * tree with this exact harness before the fix was written:
+ *
+ * completed, 草稿
+ * // → toHaveAccessibleName('') ← the visually-hidden shape
+ * …
+ * // → toHaveAccessibleName('草稿, completed')
+ *
+ * So the visually-hidden shape would have satisfied a DOM assertion ("the span
+ * is there") while delivering nothing to the accessibility tree.
+ *
+ * ── Every case here reads the ACCESSIBLE NAME, never the DOM ──────────────
+ *
+ * `toHaveAccessibleName` and the `{ name }` option of `getAllByRole` both run
+ * the accessible-name computation (`dom-accessibility-api`). Nothing below
+ * asserts a class, a span or an `aria-label` attribute — reading the attribute
+ * back would only restate the markup, which is precisely the evidence this card
+ * says is not evidence.
+ *
+ * ── Selection is positional, assertion is by name (non-circular) ──────────
+ *
+ * Per-stage cases pick the stage out by its INDEX among the row's `listitem`s and
+ * then read the name off it. Selecting by name would assume the answer: with the
+ * fix reverted every name is '', so a name-keyed query would report "no such
+ * stage" rather than "wrong name".
+ *
+ * ── Resolution path (ablation validity) ───────────────────────────────────
+ *
+ * The subject is imported RELATIVELY (`../record-path`) and the cross-package
+ * imports are mapped to each package's own `src` by the root `vitest.config.mts`
+ * alias table. Nothing here resolves through a `dist/`, so an ablation of
+ * `record-path.tsx` is visible to this suite WITHOUT a rebuild — the same
+ * property `record-path.inertReadout.test.tsx` records next door.
+ *
+ * ── Direction, predicted before running ───────────────────────────────────
+ *
+ * Deleting the `aria-label` line from `renderStage` turns every case in groups
+ * B, C and D red (names collapse to ''), and leaves group A — the #5768 readout
+ * contract: `aria-current`, the decorative glyphs, the labels on screen — GREEN,
+ * because none of it ever depended on the name. Measured, not assumed; the
+ * numbers are in the PR body.
+ *
+ * ── Provider-less on purpose ──────────────────────────────────────────────
+ *
+ * No `I18nProvider` is mounted, so `useDetailTranslation` serves
+ * `DETAIL_DEFAULT_TRANSLATIONS` and the expected strings are that map's English.
+ * The pack-backed path is a SEPARATE file
+ * (`record-path.stageStateAccessibleName.i18n.test.tsx`) because `createI18n`
+ * registers its instance as react-i18next's module-global default and that
+ * registration survives `cleanup()` — a provider-less render sharing a file with
+ * a provider-mounted one would silently resolve against whichever locale ran
+ * first. Same split, same reason, as plugin-kanban's overlay-title pair.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import '@testing-library/jest-dom';
+import { render, cleanup, within, type RenderResult } from '@testing-library/react';
+import { RecordContextProvider } from '@object-ui/react';
+import { RecordPathRenderer } from '../record-path';
+
+/** The card's own fixture: HotCRM `crm_quote`, six stages, none terminal. */
+const QUOTE_STAGES = [
+ { value: 'draft', label: '草稿' },
+ { value: 'in_review', label: '审核中' },
+ { value: 'submitted', label: '已提交' },
+ { value: 'accepted', label: '已接受' },
+ { value: 'rejected', label: '已拒绝' },
+ { value: 'expired', label: '已过期' },
+];
+
+/** A declared `lost` terminal, so the separated alt-group renders too. */
+const WITH_LOST_STAGES = [
+ { value: 'draft', label: '草稿' },
+ { value: 'in_review', label: '审核中' },
+ { value: 'submitted', label: '已提交' },
+ { value: 'declined', label: '已拒绝', terminal: 'lost' as const },
+];
+
+function mount(status: string, stages: unknown[] = QUOTE_STAGES): RenderResult {
+ return render(
+
+
+ ,
+ );
+}
+
+/**
+ * Desktop and mobile are both in the DOM at once (a CSS breakpoint separates
+ * them and this environment applies none), so every invariant is asserted on
+ * BOTH — a regression reaching only one viewport would otherwise pass.
+ */
+const rows = (r: RenderResult): HTMLElement[] =>
+ Array.from(r.container.querySelectorAll('[role="list"]')) as HTMLElement[];
+
+/** The row's stages in DOM order. Positional, so per-stage cases stay non-circular. */
+const stagesOf = (row: HTMLElement): HTMLElement[] => within(row).getAllByRole('listitem');
+
+/** Stages of `row` whose COMPUTED ACCESSIBLE NAME matches — not a DOM query. */
+const announcedAs = (row: HTMLElement, name: string | RegExp): HTMLElement[] =>
+ within(row).queryAllByRole('listitem', { name });
+
+afterEach(() => cleanup());
+
+describe('#5916 group A — the #5768 readout contract still holds', () => {
+ it('both rows render every stage, and the glyphs stay decorative', () => {
+ const r = mount('submitted');
+ expect(rows(r)).toHaveLength(2);
+
+ for (const row of rows(r)) {
+ expect(stagesOf(row)).toHaveLength(QUOTE_STAGES.length);
+ // The ✓ is decoration and must REMAIN decoration — the fix adds a text
+ // equivalent, it does not un-hide the glyph (the card's triage ruling).
+ for (const span of Array.from(row.querySelectorAll('span'))) {
+ const text = span.textContent ?? '';
+ if (text === '✓' || text === '✗') expect(span).toHaveAttribute('aria-hidden', 'true');
+ }
+ }
+ });
+
+ it('`aria-current="step"` still marks exactly the current stage', () => {
+ const r = mount('submitted');
+ for (const row of rows(r)) {
+ const marked = stagesOf(row).filter((el) => el.getAttribute('aria-current') === 'step');
+ expect(marked).toHaveLength(1);
+ expect(marked[0].textContent).toContain('已提交');
+ }
+ });
+});
+
+describe('#5916 group B — travelled / current / upcoming reach the accessible name', () => {
+ it('no stage is left nameless — non-vacuity for every case below', () => {
+ // Before the fix every one of these was ''. If the renderer ever stops
+ // naming stages this fails first, naming the file as vacuous rather than
+ // letting a later case compare '' against ''.
+ const r = mount('submitted');
+ for (const row of rows(r)) {
+ for (const stage of stagesOf(row)) expect(stage).toHaveAccessibleName();
+ }
+ });
+
+ it('a travelled stage announces that it is completed, in both rows', () => {
+ const r = mount('submitted');
+ for (const row of rows(r)) {
+ expect(stagesOf(row)[0]).toHaveAccessibleName('草稿, completed');
+ expect(stagesOf(row)[1]).toHaveAccessibleName('审核中, completed');
+ }
+ });
+
+ it('the current stage announces that it is current, in both rows', () => {
+ const r = mount('submitted');
+ for (const row of rows(r)) {
+ expect(stagesOf(row)[2]).toHaveAccessibleName('已提交, current stage');
+ }
+ });
+
+ it('an upcoming stage announces that it is upcoming, in both rows', () => {
+ const r = mount('submitted');
+ for (const row of rows(r)) {
+ expect(stagesOf(row)[3]).toHaveAccessibleName('已接受, upcoming');
+ expect(stagesOf(row)[5]).toHaveAccessibleName('已过期, upcoming');
+ }
+ });
+
+ it('THE defect, stated as a partition: six stages, three distinct announcements', () => {
+ // The card's complaint was "six identically-announced items". Each stage now
+ // falls into exactly one bucket and the buckets sum to every stage, so no
+ // stage is announced ambiguously and none was missed.
+ const r = mount('submitted');
+ for (const row of rows(r)) {
+ expect(announcedAs(row, /, completed$/)).toHaveLength(2);
+ expect(announcedAs(row, /, current stage$/)).toHaveLength(1);
+ expect(announcedAs(row, /, upcoming$/)).toHaveLength(3);
+ expect(stagesOf(row)).toHaveLength(2 + 1 + 3);
+ }
+ });
+
+ it('the state follows the record rather than the position', () => {
+ // Non-vacuity of a second kind: the names are computed from the record's
+ // value, not baked into the markup. On stage 1 nothing is completed yet.
+ const r = mount('draft');
+ for (const row of rows(r)) {
+ expect(stagesOf(row)[0]).toHaveAccessibleName('草稿, current stage');
+ expect(stagesOf(row)[1]).toHaveAccessibleName('审核中, upcoming');
+ expect(announcedAs(row, /, completed$/)).toHaveLength(0);
+ }
+ });
+});
+
+describe('#5916 group C — the lost terminal, which used to announce like any other stage', () => {
+ it('an unreached lost terminal says so, and is NOT announced as an ordinary upcoming stage', () => {
+ const r = mount('submitted', WITH_LOST_STAGES);
+ for (const row of rows(r)) {
+ expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝, closed lost, not reached');
+ // The card verbatim: "已拒绝 announces exactly like an ordinary upcoming
+ // stage". It must not. In this fixture the lost terminal is the ONLY
+ // unreached stage, so if it still announced as a plain upcoming one this
+ // query would find it.
+ expect(announcedAs(row, /, upcoming$/)).toHaveLength(0);
+ expect(announcedAs(row, /closed lost/)).toHaveLength(1);
+ }
+ });
+
+ it('a lost terminal the record SITS on announces both facts', () => {
+ const r = mount('declined', WITH_LOST_STAGES);
+ for (const row of rows(r)) {
+ expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝, closed lost, current stage');
+ // …and it is still the current step programmatically.
+ expect(stagesOf(row)[3]).toHaveAttribute('aria-current', 'step');
+ }
+ });
+
+ it('a reached lost terminal and an unreached one do not announce alike', () => {
+ const reached = mount('declined', WITH_LOST_STAGES);
+ const reachedName = /closed lost, current stage$/;
+ for (const row of rows(reached)) expect(announcedAs(row, reachedName)).toHaveLength(1);
+ cleanup();
+
+ const unreached = mount('submitted', WITH_LOST_STAGES);
+ for (const row of rows(unreached)) expect(announcedAs(row, reachedName)).toHaveLength(0);
+ });
+
+ it('the ✗ stays aria-hidden — the fix adds a text equivalent, it does not expose the glyph', () => {
+ const r = mount('submitted', WITH_LOST_STAGES);
+ const crosses = Array.from(r.container.querySelectorAll('span')).filter(
+ (s) => (s.textContent ?? '') === '✗',
+ );
+ expect(crosses.length).toBeGreaterThan(0);
+ for (const c of crosses) expect(c).toHaveAttribute('aria-hidden', 'true');
+ });
+});
+
+describe('#5916 group D — the announced name never drifts from what is on screen', () => {
+ it("every stage's accessible name contains its own visible label", () => {
+ // An author-supplied name REPLACES the contents in the name computation, so
+ // this is the invariant that keeps the two from forking: both are rendered
+ // from the same already-picklist-localized `stage.label`.
+ const r = mount('submitted');
+ for (const row of rows(r)) {
+ stagesOf(row).forEach((el, i) => {
+ expect(announcedAs(row, new RegExp(`^${QUOTE_STAGES[i].label},`))).toContain(el);
+ });
+ }
+ });
+});
diff --git a/packages/plugin-detail/src/renderers/record-path.tsx b/packages/plugin-detail/src/renderers/record-path.tsx
index bea8db9a1f..b6ad7dc027 100644
--- a/packages/plugin-detail/src/renderers/record-path.tsx
+++ b/packages/plugin-detail/src/renderers/record-path.tsx
@@ -46,12 +46,17 @@
* • `data-stage-rail` — marks the decorative indicator as an element
* SEPARATE from the label. A rail has one; a
* pill, which is its own label's surface, cannot.
+ *
+ * Those are TEST instruments, not accessibility: a `data-*` attribute is not in
+ * the accessibility tree. The same state reaches a screen reader through each
+ * stage's composed `aria-label` (objectui#5916) — see `stageAriaLabel` below.
*/
import React from 'react';
import { useRecordContext, useSafeFieldLabel } from '@object-ui/react';
import type { RecordPathComponentProps } from '@object-ui/types';
import { cn } from '@object-ui/components';
+import { useDetailTranslation } from '../useDetailTranslation';
const splitDesigner = (props: Record) => {
const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {};
@@ -73,6 +78,7 @@ export const RecordPathRenderer: React.FC = ({
}) => {
const ctx = useRecordContext();
const { translateOptions } = useSafeFieldLabel();
+ const { t } = useDetailTranslation();
const { designer } = splitDesigner(props);
const rawStages: Array<{ value: any; label: string; terminal?: 'won' | 'lost' }> = Array.isArray(schema.stages)
@@ -154,6 +160,32 @@ export const RecordPathRenderer: React.FC = ({
state === 'upcoming' && 'font-normal text-muted-foreground',
);
+ // ── The stage's STATE, in its ACCESSIBLE NAME (objectui#5916) ──────────────
+ //
+ // Travelled / upcoming / lost used to reach a screen reader through colour and
+ // a glyph, and the glyph is `aria-hidden` decoration, so a rejected stage
+ // announced exactly like an unreached one — WCAG 2.2 SC 1.4.1. `aria-current`
+ // marked the current stage and nothing else.
+ //
+ // Shape: an `aria-label` on the listitem, NOT visually-hidden text inside it.
+ // That is a measurement, not a preference — `listitem` takes its name from the
+ // AUTHOR only (it is not a name-from-contents role), so `sr-only` text placed
+ // in the item computes to an EMPTY accessible name and would satisfy a DOM
+ // assertion while delivering nothing to the accessibility tree. The composed
+ // label re-states `stage.label` — the same already-picklist-localized variable
+ // the visible text renders — so the name can never drift from what is on
+ // screen, and the ✓/✗ stay decorative.
+ const stageAriaLabel = (label: string, state: StageState, terminal?: 'won' | 'lost'): string => {
+ if (terminal === 'lost') {
+ return state === 'current'
+ ? t('detail.pathStageLostCurrent', { stage: label })
+ : t('detail.pathStageLostUpcoming', { stage: label });
+ }
+ if (state === 'current') return t('detail.pathStageCurrent', { stage: label });
+ if (state === 'completed') return t('detail.pathStageCompleted', { stage: label });
+ return t('detail.pathStageUpcoming', { stage: label });
+ };
+
const renderStage = (o: {
key: string;
stage: { label: string };
@@ -168,6 +200,7 @@ export const RecordPathRenderer: React.FC = ({
data-stage-state={o.state}
data-stage-terminal={o.terminal}
aria-current={o.state === 'current' ? 'step' : undefined}
+ aria-label={stageAriaLabel(o.stage.label, o.state, o.terminal)}
className={cn('flex flex-col gap-1.5', o.className)}
>
diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts
index 5d2e5e33c7..b7ad36896a 100644
--- a/packages/plugin-detail/src/useDetailTranslation.ts
+++ b/packages/plugin-detail/src/useDetailTranslation.ts
@@ -229,6 +229,16 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = {
'detail.cancelApprovalTooltipUnlocked': 'Recall the pending approval request',
'detail.cancelApprovalFailed': 'Failed to recall approval',
'detail.cancelApprovalUnavailable': 'Recalling approvals is not supported on this data source',
+ // `record:path` stage state, composed into each stage's accessible name
+ // (objectui#5916). A `role="listitem"` takes its name from the AUTHOR only —
+ // visually-hidden text inside one computes to an empty accessible name — so the
+ // state cannot ride along as content and is composed into `aria-label` instead.
+ // `{{stage}}` is the stage's own label, already localized via `translateOptions`.
+ 'detail.pathStageCompleted': '{{stage}}, completed',
+ 'detail.pathStageCurrent': '{{stage}}, current stage',
+ 'detail.pathStageUpcoming': '{{stage}}, upcoming',
+ 'detail.pathStageLostCurrent': '{{stage}}, closed lost, current stage',
+ 'detail.pathStageLostUpcoming': '{{stage}}, closed lost, not reached',
};
/**