From e5ff3d0eed718e3c60792082514574faadb83e2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 11:04:51 +0000 Subject: [PATCH 1/2] fix(plugin-detail,i18n): localize record:path's list label, drop its inert group label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `record:path` had two container `aria-label` literals that objectui#5916 deliberately left alone while it localized every stage inside them. They were two different defects and get two different fixes. 1. Both the desktop and the mobile `role="list"` row did `aria-label={schema.aria?.label || 'Record path'}`. A zh/ja/ar session heard `Record path` for the list while every stage inside announced in the session locale — one control speaking two languages at once. The fallback is now `detail.pathLabel`, added to all ten packs with real translations and to `DETAIL_DEFAULT_TRANSLATIONS` (which `defaults-maps-mirror-en-pack` compares against the `en` pack key by key, so the two hosts cannot fork). The `schema.aria.label` author override still wins ahead of it. 2. The lost-terminal alt group carried `aria-label="Alternative terminal stages"` on a bare `div`. A `div` is `generic`, and browsers expose no accessible name on a generic element, so that string reached nobody: inert, not merely untranslated. Translating it would have shipped copy to ten packs that no user can hear. It is removed rather than given a `role` that takes a name, on three measurements recorded in the renderer: nothing is lost (it was never announced); it would be redundant (every stage inside already announces `closed lost` in the session locale after objectui#5916, in the one place `role="list"` can carry it); and it would fork the two rows (the mobile row renders one flat list with no alt group, so a named group would make one control expose two structures by viewport). `record-path.containerLabel.test.tsx` pins both halves: the list name follows the session locale across en/zh/de/ja with a non-vacuity case, the author override still wins, no element claims the old group name or a named role, and the lost stages still announce their own terminal state — the positive fact that makes the removal safe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + .../record-path.containerLabel.test.tsx | 156 ++++++++++++++++++ .../src/renderers/record-path.tsx | 35 +++- .../plugin-detail/src/useDetailTranslation.ts | 6 + 13 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-path.containerLabel.test.tsx diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 6a5ab61b94..86262f5cba 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -978,6 +978,7 @@ const ar = { cancelApprovalTooltipUnlocked: "إلغاء طلب الموافقة المعلق", cancelApprovalFailed: "فشل إلغاء الموافقة", cancelApprovalUnavailable: "إلغاء الموافقات غير مدعوم من مصدر البيانات هذا", + pathLabel: 'مسار السجل', pathStageCompleted: '{{stage}}، مكتملة', pathStageCurrent: '{{stage}}، المرحلة الحالية', pathStageUpcoming: '{{stage}}، قادمة', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index cf15c1a9b6..9cde74b852 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -972,6 +972,7 @@ 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", + pathLabel: 'Datensatzpfad', pathStageCompleted: '{{stage}}, abgeschlossen', pathStageCurrent: '{{stage}}, aktuelle Phase', pathStageUpcoming: '{{stage}}, ausstehend', diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 9fdd43389a..c30c9e6633 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -948,6 +948,7 @@ const en = { // 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. + pathLabel: 'Record path', pathStageCompleted: '{{stage}}, completed', pathStageCurrent: '{{stage}}, current stage', pathStageUpcoming: '{{stage}}, upcoming', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 888060d288..2f27aedc84 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -976,6 +976,7 @@ 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", + pathLabel: 'Ruta del registro', pathStageCompleted: '{{stage}}, completada', pathStageCurrent: '{{stage}}, etapa actual', pathStageUpcoming: '{{stage}}, pendiente', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 897ab3bef2..9897d0de08 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -974,6 +974,7 @@ 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", + pathLabel: "Parcours de l'enregistrement", pathStageCompleted: '{{stage}}, terminée', pathStageCurrent: '{{stage}}, étape actuelle', pathStageUpcoming: '{{stage}}, à venir', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 54327ba121..529737e9d1 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -983,6 +983,7 @@ const ja = { cancelApprovalTooltipUnlocked: "承認待ちリクエストを取り消す", cancelApprovalFailed: "承認の取り消しに失敗しました", cancelApprovalUnavailable: "このデータソースでは承認の取り消しはサポートされていません", + pathLabel: 'レコードパス', pathStageCompleted: '{{stage}}、完了', pathStageCurrent: '{{stage}}、現在のステージ', pathStageUpcoming: '{{stage}}、未着手', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 84132a91f8..b20947845b 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -972,6 +972,7 @@ const ko = { cancelApprovalTooltipUnlocked: "대기 중인 승인 요청 취소", cancelApprovalFailed: "승인 취소 실패", cancelApprovalUnavailable: "이 데이터 소스에서는 승인 취소가 지원되지 않습니다", + pathLabel: '레코드 경로', pathStageCompleted: '{{stage}}, 완료됨', pathStageCurrent: '{{stage}}, 현재 단계', pathStageUpcoming: '{{stage}}, 예정', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index d34faaf086..803f1ca1e1 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -973,6 +973,7 @@ 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", + pathLabel: 'Caminho do registro', pathStageCompleted: '{{stage}}, concluída', pathStageCurrent: '{{stage}}, etapa atual', pathStageUpcoming: '{{stage}}, pendente', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index cf3c8facc3..cbc1c724e3 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -991,6 +991,7 @@ const ru = { cancelApprovalTooltipUnlocked: "Отменить ожидающий запрос на согласование", cancelApprovalFailed: "Не удалось отменить согласование", cancelApprovalUnavailable: "Отмена согласований не поддерживается этим источником данных", + pathLabel: 'Путь записи', pathStageCompleted: '{{stage}}, завершён', pathStageCurrent: '{{stage}}, текущий этап', pathStageUpcoming: '{{stage}}, предстоит', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 5d6f2e9062..caa7f00f23 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -879,6 +879,7 @@ const zh = { cancelApprovalTooltipUnlocked: '撤回当前的待审批请求', cancelApprovalFailed: '撤回审批失败', cancelApprovalUnavailable: '当前数据源不支持撤回审批', + pathLabel: '记录路径', pathStageCompleted: '{{stage}},已完成', pathStageCurrent: '{{stage}},当前阶段', pathStageUpcoming: '{{stage}},未开始', diff --git a/packages/plugin-detail/src/renderers/__tests__/record-path.containerLabel.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-path.containerLabel.test.tsx new file mode 100644 index 0000000000..661c88164e --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-path.containerLabel.test.tsx @@ -0,0 +1,156 @@ +/** + * 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. + * + * ══════════════════════════════════════════════════════════════════════════ + * `record:path`'s CONTAINER labels (objectui#5956) + * ══════════════════════════════════════════════════════════════════════════ + * + * objectui#5916 localized every STAGE's accessible name and deliberately left + * the two container labels alone. That left one control announcing in two + * languages at once: a zh session heard `Record path` for the list itself while + * every stage inside it announced in Chinese. This file pins the fix — and the + * OTHER container label, which was a different defect entirely. + * + * ── Two defects, two shapes of assertion ────────────────────────────────── + * + * 1. `role="list"` on both rows: a hardcoded English literal became + * `detail.pathLabel`, with `schema.aria.label` still winning ahead of it. + * Assertable as an accessible NAME, because `list` takes a name from the + * author. + * + * 2. The lost-terminal alt group: `aria-label="Alternative terminal stages"` sat + * on a bare `div`. A `div` is `generic`, and browsers expose no accessible + * name on a generic element — so that string reached NOBODY. It was inert, + * not untranslated. The assertion below is therefore an ABSENCE (no element + * carries that name and no wrapper claims a named role), paired with the + * positive fact that makes the absence safe: the stages inside already + * announce `closed lost` themselves, so nothing a user could hear is lost. + * The renderer's own comment records the three measurements behind the pick. + * + * ── Why every case here mounts a provider ───────────────────────────────── + * + * `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 + * — the same hazard `record-path.stageStateAccessibleName.i18n.test.tsx` records + * and splits two files over. Rather than re-open that split, every case here is + * provider-mounted. The provider-less path's own invariant — that + * `DETAIL_DEFAULT_TRANSLATIONS` serves the same bytes the `en` pack does, so + * `detail.pathLabel` cannot fork by host — is owned globally and per key by + * `app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx` (objectui#4401), + * which compares the map against the pack key by key and now covers this row. + */ + +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, schemaExtra: Record = {}): RenderResult { + return render( + + + + + , + ); +} + +/** Both rows are rendered on every host; the viewport split is CSS-only. */ +const rows = (r: RenderResult): HTMLElement[] => + Array.from(r.container.querySelectorAll('[role="list"]')) as HTMLElement[]; + +afterEach(() => cleanup()); + +describe('record:path container label speaks the session locale (objectui#5956)', () => { + it('en names both rows from the pack', () => { + const [desktop, mobile] = rows(mountIn('en')); + expect(desktop).toHaveAccessibleName('Record path'); + expect(mobile).toHaveAccessibleName('Record path'); + }); + + it('zh names both rows in Chinese — no English left on the container', () => { + const [desktop, mobile] = rows(mountIn('zh')); + expect(desktop).toHaveAccessibleName('记录路径'); + expect(mobile).toHaveAccessibleName('记录路径'); + // The point of the card: the list must not announce in English while the + // stages inside it announce in Chinese. + for (const row of rows(mountIn('zh'))) { + expect(row).not.toHaveAccessibleName(/Record path/); + } + }); + + it('de and ja name both rows in their own locale', () => { + for (const [lang, name] of [ + ['de', 'Datensatzpfad'], + ['ja', 'レコードパス'], + ] as const) { + for (const row of rows(mountIn(lang))) expect(row).toHaveAccessibleName(name); + cleanup(); + } + }); + + 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']) { + seen.add(rows(mountIn(lang))[0].getAttribute('aria-label') ?? ''); + cleanup(); + } + expect(seen.size).toBe(3); + }); + + it('an author `schema.aria.label` still wins ahead of the pack fallback', () => { + // The override is the whole reason the literal was a FALLBACK; localizing it + // must not promote the pack above what the author asked for. + for (const row of rows(mountIn('zh', { aria: { label: 'Deal stages' } }))) { + expect(row).toHaveAccessibleName('Deal stages'); + } + }); +}); + +describe('the lost-terminal alt group carries no inert label (objectui#5956)', () => { + it('no element anywhere claims the old hardcoded group name', () => { + const r = mountIn('en'); + // It was never announced — it sat on a `generic` element. Removing it costs + // no user anything, and it must not come back untranslated either. + expect(r.container.querySelector('[aria-label="Alternative terminal stages"]')).toBeNull(); + expect(r.container.textContent).not.toContain('Alternative terminal stages'); + }); + + it('the alt-group wrapper claims no named role at all', () => { + const r = mountIn('en'); + const desktop = rows(r)[0]; + // Every element inside the desktop row is either the list itself, a stage + // `listitem`, a presentational wrapper, or a stage's own decoration — no + // `group`/`region`/`navigation` box was introduced to hold a name. + expect(desktop.querySelectorAll('[role="group"],[role="region"],[role="navigation"]')).toHaveLength(0); + }); + + it('what the group would have said is already on the stages themselves', () => { + // The positive half: the absence above is only safe because each lost stage + // announces its own terminal state, in the session locale, after #5916. + const zhRow = rows(mountIn('zh'))[0]; + const zhStages = within(zhRow).getAllByRole('listitem'); + expect(zhStages[3]).toHaveAccessibleName('已拒绝,已失败,未到达'); + cleanup(); + const enRow = rows(mountIn('en'))[0]; + const enStages = within(enRow).getAllByRole('listitem'); + expect(enStages[3]).toHaveAccessibleName('已拒绝, closed lost, not reached'); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-path.tsx b/packages/plugin-detail/src/renderers/record-path.tsx index b6ad7dc027..8f4e98b6b0 100644 --- a/packages/plugin-detail/src/renderers/record-path.tsx +++ b/packages/plugin-detail/src/renderers/record-path.tsx @@ -222,7 +222,7 @@ export const RecordPathRenderer: React.FC = ({
{forwardStages.map((stage, idx) => { @@ -241,8 +241,35 @@ export const RecordPathRenderer: React.FC = ({
{lostStages.length > 0 && ( // Separated alt-terminus group — a gap and a divider, so it does not - // read as "step N+1" in the forward path. -
+ // read as "step N+1" in the forward path. That separation is PURELY + // VISUAL, and deliberately carries no accessible name (objectui#5956). + // + // It used to hold `aria-label="Alternative terminal stages"` on this + // bare `div`. A `div` has the `generic` role, which browsers do not + // expose an accessible name on, so that string reached nobody: it was + // INERT, not merely untranslated, and translating it would have shipped + // copy to ten packs that no user can hear. The two live options were to + // give this wrapper a role that takes a name (`group`) or to drop the + // label. Dropping it wins on three measurements: + // + // 1. Nothing is lost. The label was never announced, so no user's + // experience changes by removing it — whereas NAMING the group is + // new verbosity on every traversal of this row. + // 2. It would be redundant. Every stage inside already announces + // `closed lost` in the session locale (objectui#5916, + // `detail.pathStageLost*`), so "these are alternative terminal + // stages" is already carried item by item, in the one place + // `role="list"` can carry it. The forward/alt distinction is + // ALREADY in the accessible name. + // 3. It would fork the two rows. The mobile row below renders every + // stage in ONE flat list with no alt group at all, so a named + // group here would make one control expose two different + // structures by viewport — against the invariant + // `record-path.stageStateAccessibleName.i18n.test.tsx` states + // ("both rows carry identical names"). + // + // So the wrapper stays a presentational box. +
{lostStages.map((stage, lIdx) => { const absIdx = firstLostIdx + lIdx; return renderStage({ @@ -262,7 +289,7 @@ export const RecordPathRenderer: React.FC = ({
{stages.map((stage, idx) => { const kind = stageKinds[idx]; diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index b7ad36896a..60d2b939b7 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -229,6 +229,12 @@ 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`'s own container label — the accessible name of the stage LIST + // (objectui#5956). Was a hardcoded `'Record path'` literal in the renderer, so + // a zh/ja/ar session heard English for the list while every stage inside it + // announced in the session locale. The `schema.aria.label` author override + // still wins ahead of this fallback. + 'detail.pathLabel': 'Record path', // `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 From a178b0237bc4306eae690eaf6894b91665573888 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 11:08:08 +0000 Subject: [PATCH 2/2] fix(plugin-detail,i18n): announce an unreached record:path goal terminus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `railClass` paints an unreached `won` terminus `bg-emerald-500/30` where a plain upcoming stage gets `bg-muted` — the renderer's own note calls this "a faint emerald so the goal is legible" — while both announced the identical `{{stage}}, upcoming`. Two stages ahead of the record painted differently and read the same: the WCAG 2.2 SC 1.4.1 class objectui#5916 closed, on the one distinction it left behind. Reachable without authors opting in, because `classify()` finds `won` through the `WON_TOKENS` heuristic (`won|success|成交|赢|完成`) as well as an explicit `terminal: 'won'`. New key `detail.pathStageWonUpcoming` — `{{stage}}, goal stage, not reached` — in all ten packs and in `DETAIL_DEFAULT_TRANSLATIONS`. "Goal" is the file's own word for this stage, not new vocabulary. Scoped to the UNREACHED goal, which is a measurement of the stylesheet rather than a preference. A reached goal terminus paints `bg-primary` when current and `bg-emerald-500` when completed — byte-identical to any other current or completed stage — so naming it apart would hand a screen reader a distinction the screen does not make, which is the mirror image of the defect, and would be dead copy in ten packs besides. One new key, not a pair; a test pins that decision so it cannot drift into a fourth state unnoticed. `record-path.wonTerminusAccessibleName.test.tsx` asserts the card's criterion as the PAIR it is — the goal stage's name must differ from a plain upcoming stage's while both carry `data-stage-state="upcoming"` — rather than only against a literal, which would still pass if every upcoming stage later became a goal stage. Both the explicit and the heuristic route are exercised, on both rows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- .../5956-5957-record-path-a11y-residue.md | 46 ++++ packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + ...rd-path.wonTerminusAccessibleName.test.tsx | 205 ++++++++++++++++++ .../src/renderers/record-path.tsx | 33 +++ .../plugin-detail/src/useDetailTranslation.ts | 7 + 14 files changed, 301 insertions(+) create mode 100644 .changeset/5956-5957-record-path-a11y-residue.md create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-path.wonTerminusAccessibleName.test.tsx diff --git a/.changeset/5956-5957-record-path-a11y-residue.md b/.changeset/5956-5957-record-path-a11y-residue.md new file mode 100644 index 0000000000..d57e248f10 --- /dev/null +++ b/.changeset/5956-5957-record-path-a11y-residue.md @@ -0,0 +1,46 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +--- + +`record:path` finishes localizing and de-colouring its accessible names — the two residues +objectui#5916 named and deliberately left behind (objectui#5956, objectui#5957). + +**The list's own label was English on a localized surface, and the other one named nothing.** +Both the desktop and the mobile `role="list"` row did +`aria-label={schema.aria?.label || 'Record path'}`, so a zh/ja/ar session heard `Record path` +for the list while every stage inside it announced in the session locale — one control +speaking two languages at once. The fallback is now `detail.pathLabel`, translated in all ten +packs; the `schema.aria.label` author override still wins ahead of it. + +The lost-terminal alt group was a different defect wearing the same clothes: its +`aria-label="Alternative terminal stages"` sat on a bare `div`, which has the `generic` role, +and browsers expose no accessible name on a generic element. That string reached nobody — +inert, not merely untranslated — so translating it would have shipped copy to ten packs that +no user can hear. It is removed rather than given a role that takes a name, on three +measurements: nothing is lost (it was never announced), it would be redundant (every stage +inside already announces `closed lost` in the session locale after objectui#5916, in the one +place `role="list"` can carry it), and it would fork the two rows (the mobile row renders one +flat list with no alt group, so a named group would make one control expose two structures by +viewport). + +**An unreached goal terminus was distinguished by hue alone.** `railClass` paints it +`bg-emerald-500/30` where a plain upcoming stage gets `bg-muted` — the renderer's own note +calls this "a faint emerald so the goal is legible" — while both announced the identical +`{{stage}}, upcoming`. Two stages ahead of the record painted differently and read the same: +the WCAG 2.2 SC 1.4.1 class objectui#5916 closed, on the one distinction it left behind, and +reachable without authors opting in because `classify()` finds `won` through the `WON_TOKENS` +heuristic as well as an explicit `terminal: 'won'`. New key `detail.pathStageWonUpcoming` +(`{{stage}}, goal stage, not reached`), translated in all ten packs. + +Scoped to the UNREACHED goal, which is a measurement of the stylesheet rather than a +preference: a reached goal terminus paints `bg-primary` when current and `bg-emerald-500` when +completed, byte-identical to any other current or completed stage. Naming it apart would hand +a screen reader a distinction the screen does not make — the mirror image of the defect — so +it is one new key, not a pair, and a test pins that decision so it cannot drift into a fourth +state unnoticed. + +Both new keys also land in `DETAIL_DEFAULT_TRANSLATIONS`, which +`defaults-maps-mirror-en-pack` compares against the `en` pack key by key, so neither can fork +between a provider-mounted console and a provider-less embed. No existing `en` value changes, +so no pack is asked to follow an edit. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 86262f5cba..e338b4f133 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -984,6 +984,7 @@ const ar = { pathStageUpcoming: '{{stage}}، قادمة', pathStageLostCurrent: '{{stage}}، خاسرة، المرحلة الحالية', pathStageLostUpcoming: '{{stage}}، خاسرة، لم يتم الوصول إليها', + pathStageWonUpcoming: '{{stage}}، المرحلة الهدف، لم يتم الوصول إليها', linkCopied: "تم نسخ الرابط إلى الحافظة", linkCopyFailed: "فشل نسخ الرابط", cancel: "إلغاء", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 9cde74b852..4b003453d6 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -978,6 +978,7 @@ const de = { pathStageUpcoming: '{{stage}}, ausstehend', pathStageLostCurrent: '{{stage}}, verloren, aktuelle Phase', pathStageLostUpcoming: '{{stage}}, verloren, nicht erreicht', + pathStageWonUpcoming: '{{stage}}, Zielphase, 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 c30c9e6633..c0dd02ebce 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -954,6 +954,7 @@ const en = { pathStageUpcoming: '{{stage}}, upcoming', pathStageLostCurrent: '{{stage}}, closed lost, current stage', pathStageLostUpcoming: '{{stage}}, closed lost, not reached', + pathStageWonUpcoming: '{{stage}}, goal stage, 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 2f27aedc84..2e63e6fdb2 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -982,6 +982,7 @@ const es = { pathStageUpcoming: '{{stage}}, pendiente', pathStageLostCurrent: '{{stage}}, perdida, etapa actual', pathStageLostUpcoming: '{{stage}}, perdida, no alcanzada', + pathStageWonUpcoming: '{{stage}}, etapa objetivo, 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 9897d0de08..471260aed2 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -980,6 +980,7 @@ const fr = { pathStageUpcoming: '{{stage}}, à venir', pathStageLostCurrent: '{{stage}}, perdue, étape actuelle', pathStageLostUpcoming: '{{stage}}, perdue, non atteinte', + pathStageWonUpcoming: '{{stage}}, étape objectif, 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 529737e9d1..6b41f7330c 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -989,6 +989,7 @@ const ja = { pathStageUpcoming: '{{stage}}、未着手', pathStageLostCurrent: '{{stage}}、失注、現在のステージ', pathStageLostUpcoming: '{{stage}}、失注、未到達', + pathStageWonUpcoming: '{{stage}}、目標ステージ、未到達', linkCopied: "リンクをクリップボードにコピーしました", linkCopyFailed: "リンクのコピーに失敗しました", cancel: "キャンセル", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index b20947845b..10143814a4 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -978,6 +978,7 @@ const ko = { pathStageUpcoming: '{{stage}}, 예정', pathStageLostCurrent: '{{stage}}, 실패, 현재 단계', pathStageLostUpcoming: '{{stage}}, 실패, 도달하지 않음', + pathStageWonUpcoming: '{{stage}}, 목표 단계, 도달하지 않음', linkCopied: "링크가 클립보드에 복사됨", linkCopyFailed: "링크 복사 실패", cancel: "취소", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 803f1ca1e1..687a9daedd 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -979,6 +979,7 @@ const pt = { pathStageUpcoming: '{{stage}}, pendente', pathStageLostCurrent: '{{stage}}, perdida, etapa atual', pathStageLostUpcoming: '{{stage}}, perdida, não alcançada', + pathStageWonUpcoming: '{{stage}}, etapa objetivo, 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 cbc1c724e3..356125f2fa 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -997,6 +997,7 @@ const ru = { pathStageUpcoming: '{{stage}}, предстоит', pathStageLostCurrent: '{{stage}}, проигран, текущий этап', pathStageLostUpcoming: '{{stage}}, проигран, не достигнут', + pathStageWonUpcoming: '{{stage}}, целевой этап, не достигнут', linkCopied: "Ссылка скопирована в буфер обмена", linkCopyFailed: "Не удалось скопировать ссылку", cancel: "Отмена", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index caa7f00f23..c3ee69dc27 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -885,6 +885,7 @@ const zh = { pathStageUpcoming: '{{stage}},未开始', pathStageLostCurrent: '{{stage}},已失败,当前阶段', pathStageLostUpcoming: '{{stage}},已失败,未到达', + pathStageWonUpcoming: '{{stage}},目标阶段,未到达', linkCopied: '链接已复制到剪贴板', linkCopyFailed: '复制链接失败', cancel: '取消', diff --git a/packages/plugin-detail/src/renderers/__tests__/record-path.wonTerminusAccessibleName.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-path.wonTerminusAccessibleName.test.tsx new file mode 100644 index 0000000000..41a58156ef --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-path.wonTerminusAccessibleName.test.tsx @@ -0,0 +1,205 @@ +/** + * 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 GOAL terminus in the accessible name (objectui#5957) + * ══════════════════════════════════════════════════════════════════════════ + * + * objectui#5916 gave every stage a name carrying travelled / current / upcoming + * / lost-terminal, and deliberately left the `won` terminus announcing through + * the ordinary three. The renderer still distinguished it — and only visually: + * + * terminal !== 'lost' && state === 'upcoming' + * && (terminal === 'won' ? 'bg-emerald-500/30' : 'bg-muted') + * + * so two stages ahead of the record painted differently and announced + * identically as `{{stage}}, upcoming`. The same WCAG 2.2 SC 1.4.1 class #5916 + * closed, on the one distinction it left behind. + * + * ── The criterion, asserted as a DIFFERENCE, not as a string ────────────── + * + * "Two upcoming stages that paint differently must not announce identically" is + * a claim about a PAIR, so the load-bearing case below compares the two names to + * each other rather than each to a literal. A literal-only suite would still + * pass if some later edit made every upcoming stage announce as a goal stage. + * + * ── The scope decision this file also pins ──────────────────────────────── + * + * Only the UNREACHED goal terminus gets a name of its own. A reached one paints + * `bg-primary` when current and `bg-emerald-500` when completed — byte-identical + * to any other current/completed stage — so naming it apart would hand a screen + * reader a distinction the screen does not make, which is the mirror image of + * the defect. That is a decision, so it is pinned as one (`a reached goal + * terminus announces as an ordinary current stage`) rather than left to drift + * silently into a fourth state later. + * + * ── Reachability without authors opting in ──────────────────────────────── + * + * `classify()` reaches `won` from an explicit `terminal: 'won'` AND from the + * `WON_TOKENS` heuristic (`won|success|成交|赢|完成`), so both routes are + * exercised — a fix that only honoured the explicit spelling would leave every + * Salesforce-style `closed_won` picklist on the defect. + * + * Every case mounts a provider: `createI18n` registers its instance as + * react-i18next's module-global default and the registration survives + * `cleanup()`, so a provider-less render here would resolve against whichever + * locale a previous case mounted (the hazard + * `record-path.stageStateAccessibleName.i18n.test.tsx` splits two files over). + * The provider-less path's own invariant — `DETAIL_DEFAULT_TRANSLATIONS` serving + * the same bytes as the `en` pack — is owned by + * `app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx` (objectui#4401). + */ + +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'; + +/** The goal terminus declared outright by the author. */ +const EXPLICIT_STAGES = [ + { value: 'draft', label: '草稿' }, + { value: 'negotiation', label: '谈判中' }, + { value: 'closed_won', label: '已成交', terminal: 'won' as const }, + { value: 'closed_lost', label: '已流失', terminal: 'lost' as const }, +]; + +/** The same path with NO `terminal` on the goal — `WON_TOKENS` has to find it. */ +const HEURISTIC_STAGES = [ + { value: 'draft', label: '草稿' }, + { value: 'negotiation', label: '谈判中' }, + { value: 'closed_won', label: '已成交' }, + { value: 'closed_lost', label: '已流失', terminal: 'lost' as const }, +]; + +function mountIn( + language: string, + status: string, + stages: ReadonlyArray> = EXPLICIT_STAGES, +): RenderResult { + return render( + + + + + , + ); +} + +const rows = (r: RenderResult): HTMLElement[] => + Array.from(r.container.querySelectorAll('[role="list"]')) as HTMLElement[]; + +const stagesOf = (row: HTMLElement): HTMLElement[] => within(row).getAllByRole('listitem'); + +/** + * The state half of a composed name — everything the pack contributes once the + * stage's own label is removed. Every `detail.pathStage*` value is + * `'{{stage}}' + state`, so stripping the label leaves exactly the state. + */ +const stateHalf = (item: HTMLElement): string => { + const name = item.getAttribute('aria-label') ?? ''; + const label = (item.querySelector('span:last-of-type')?.textContent ?? '').trim(); + return name.startsWith(label) ? name.slice(label.length) : name; +}; + +/** Index 1 is a plain upcoming stage; index 2 is the goal terminus. */ +const PLAIN_UPCOMING = 1; +const GOAL = 2; + +afterEach(() => cleanup()); + +describe('an unreached goal terminus announces as one (objectui#5957)', () => { + it('en names it a goal stage instead of a plain upcoming stage', () => { + const [desktop, mobile] = rows(mountIn('en', 'draft')); + expect(stagesOf(desktop)[GOAL]).toHaveAccessibleName('已成交, goal stage, not reached'); + // Both rows, so the fix does not land on one viewport only. + expect(stagesOf(mobile)[GOAL]).toHaveAccessibleName('已成交, goal stage, not reached'); + }); + + it('two upcoming stages that PAINT differently no longer ANNOUNCE identically', () => { + // The card's criterion, asserted as the pair it is. Before the fix both of + // these read `{{stage}}, upcoming` while the rails differed by hue alone. + const desktop = rows(mountIn('en', 'draft'))[0]; + const items = stagesOf(desktop); + const plain = items[PLAIN_UPCOMING]; + const goal = items[GOAL]; + + // Same state — so the ONLY thing that distinguished them was colour. + expect(plain).toHaveAttribute('data-stage-state', 'upcoming'); + expect(goal).toHaveAttribute('data-stage-state', 'upcoming'); + // And the renderer really does treat them as different kinds of upcoming. + expect(plain).not.toHaveAttribute('data-stage-terminal'); + expect(goal).toHaveAttribute('data-stage-terminal', 'won'); + + // Compare the STATE half, not the whole name. Each name is `{{stage}}` plus + // its state, and the two stages carry different labels — so comparing whole + // names is VACUOUS: it stays green with the fix reverted, purely because + // `已成交` is not `谈判中`. Measured, by deleting the `won` branch and + // watching this case pass anyway. The state half is the half the card is + // about, and it is what colour was carrying alone. + expect(stateHalf(goal)).not.toBe(stateHalf(plain)); + // ...and it is genuinely the goal wording, not merely some other string. + expect(stateHalf(goal)).toBe(', goal stage, not reached'); + expect(stateHalf(plain)).toBe(', upcoming'); + }); + + it('the heuristic route reaches it too — no author opt-in required', () => { + // `classify()` finds `won` in `closed_won` via WON_TOKENS with no `terminal` + // in the config, which is how Salesforce-style picklists arrive. + const desktop = rows(mountIn('en', 'draft', HEURISTIC_STAGES))[0]; + const goal = stagesOf(desktop)[GOAL]; + expect(goal).toHaveAttribute('data-stage-terminal', 'won'); + expect(goal).toHaveAccessibleName('已成交, goal stage, not reached'); + }); + + it('zh and de announce the goal state in their own locale', () => { + for (const [lang, name] of [ + ['zh', '已成交,目标阶段,未到达'], + ['de', '已成交, Zielphase, nicht erreicht'], + ] as const) { + expect(stagesOf(rows(mountIn(lang, 'draft'))[0])[GOAL]).toHaveAccessibleName(name); + cleanup(); + } + }); + + it('a zh session hears no English in the goal stage name', () => { + const goal = stagesOf(rows(mountIn('zh', 'draft'))[0])[GOAL]; + expect(goal).not.toHaveAccessibleName(/goal stage|not reached|upcoming/); + }); + + 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']) { + seen.add(stagesOf(rows(mountIn(lang, 'draft'))[0])[GOAL].getAttribute('aria-label') ?? ''); + cleanup(); + } + expect(seen.size).toBe(3); + }); +}); + +describe('a REACHED goal terminus stays an ordinary stage — deliberately', () => { + it('announces as a plain current stage, because it paints as one', () => { + // `railClass` gives a current stage `bg-primary` whether or not it is the + // goal, so there is no colour-only distinction here to mirror. Naming it + // apart would ADD information the screen does not carry. Pinned so the + // decision cannot drift into a fourth state unnoticed. + const desktop = rows(mountIn('en', 'closed_won'))[0]; + const goal = stagesOf(desktop)[GOAL]; + expect(goal).toHaveAttribute('data-stage-state', 'current'); + expect(goal).toHaveAttribute('data-stage-terminal', 'won'); + expect(goal).toHaveAccessibleName('已成交, current stage'); + }); + + it('the lost terminus is untouched by any of this (objectui#5916 stays closed)', () => { + const desktop = rows(mountIn('en', 'draft'))[0]; + expect(stagesOf(desktop)[3]).toHaveAccessibleName('已流失, closed lost, not reached'); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-path.tsx b/packages/plugin-detail/src/renderers/record-path.tsx index 8f4e98b6b0..36e8a76b0a 100644 --- a/packages/plugin-detail/src/renderers/record-path.tsx +++ b/packages/plugin-detail/src/renderers/record-path.tsx @@ -181,6 +181,39 @@ export const RecordPathRenderer: React.FC = ({ ? t('detail.pathStageLostCurrent', { stage: label }) : t('detail.pathStageLostUpcoming', { stage: label }); } + // ── The GOAL terminus, when it has NOT been reached (objectui#5957) ──── + // + // `railClass` above paints an unreached `won` terminus `bg-emerald-500/30` + // where an ordinary unreached stage gets `bg-muted` — its own note calls + // this "a faint emerald so the goal is legible". That legibility was carried + // by HUE ALONE: two stages ahead of the record painted differently and + // announced identically as `{{stage}}, upcoming`. Same WCAG 2.2 SC 1.4.1 + // class objectui#5916 closed, on the one distinction it left behind, and + // reachable without authors opting in — `classify()` reaches `won` from an + // explicit `terminal: 'won'` AND from the `WON_TOKENS` heuristic. + // + // Scoped to `upcoming` deliberately, and that scope is a MEASUREMENT of the + // stylesheet above rather than a preference. The defect is information + // carried by colour alone, so the name may only restate a distinction the + // colour actually makes: + // + // • upcoming + won → `bg-emerald-500/30`, against `bg-muted` for a plain + // upcoming stage. A real distinction: it gets a name. + // • current + won → `bg-primary`, identical to every other current + // stage. No visual distinction exists, so announcing + // one would GIVE a screen-reader user information a + // sighted user does not get — the mirror image of the + // defect, and dead copy in ten packs besides. + // • completed + won → `bg-emerald-500`, again identical to every other + // completed stage. Same answer. + // + // So a REACHED goal terminus keeps announcing as an ordinary current / + // completed stage. One new key, not a pair. + // + // `terminal` here is the SAME value `renderStage` hands `railClass`, so the + // name tracks the paint on each row by construction, not by a second and + // drift-prone classification. + if (terminal === 'won' && state === 'upcoming') return t('detail.pathStageWonUpcoming', { 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 }); diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index 60d2b939b7..bef6813589 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -245,6 +245,13 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.pathStageUpcoming': '{{stage}}, upcoming', 'detail.pathStageLostCurrent': '{{stage}}, closed lost, current stage', 'detail.pathStageLostUpcoming': '{{stage}}, closed lost, not reached', + // The GOAL terminus, unreached (objectui#5957). An unreached `won` stage is + // painted a faint emerald where a plain upcoming stage is `bg-muted`, and that + // was the last distinction this control carried in COLOUR ALONE. Only the + // `upcoming` state gets a key: a REACHED `won` terminus paints identically to + // any other current/completed stage, so naming it apart would hand a screen + // reader a distinction the screen does not make. + 'detail.pathStageWonUpcoming': '{{stage}}, goal stage, not reached', }; /**