Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

record:path conveys travelled / upcoming / lost stages by colour alone — the ✓ and ✗ are aria-hidden, so the state never reaches the accessible name #5916

Closed

Description

@claude

Found while working the lifecycle-path readout card in packages/plugin-detail/src/renderers/record-path.tsx. Pre-existing, not introduced by that change, and outside its fence — filing rather than folding it in.

What a screen-reader user gets

record:path distinguishes travelled stages from upcoming ones with colour and a check glyph, and the glyph is aria-hidden:

<span aria-hidden="true" className="... text-emerald-600 ...">✓</span>
{stage.label}

The only programmatic signal on any stage is aria-current="step" on the current one. So a screen-reader user hears a list of six identically-announced items with one marked "current step" — and cannot tell that 草稿 and 审核中 are already behind the record while 已接受 and 已过期 are ahead of it. The same holds for a lost terminal: its ✗ is aria-hidden too, so 已拒绝 announces exactly like an ordinary upcoming stage.

Sighted users get this distinction from colour alone (emerald = travelled, muted = upcoming, destructive = lost terminal), which is WCAG 2.2 SC 1.4.1 Use of Color.

Reproduce

Any object declaring a stageField renders this chrome on its record page. Put the record on a middle stage and read the path with a screen reader, or inspect the accessible tree: the list items' accessible names are the bare stage labels.

Shape of a change

The stage's state needs a text equivalent that reaches the accessible name — e.g. visually-hidden text per stage, or an aria-label composing label + state. Both introduce user-facing strings, so this needs i18n keys rather than the hardcoded English the component currently uses for its container labels; that is why it is its own card rather than a one-line addition.

Related but distinct: #5569 tracks duplication of the approval stepper, a different surface with its own markup.

Context: the readout markup was reworked in claude/issue-5768-inert-lifecycle-path, which de-buttoned the path but deliberately did not move its accessible semantics.


Generated by Claude Code


Generated by Claude Code

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 24, 2026
  2. added theissue type on Aug 24, 2026
  3. claude commented on Aug 24, 2026

    @claude
    ContributorAuthor

    Triage (daily round, session session_01Kktexqp6uVuFMztvvTMf3V, 2026-08-24): lands in packages/plugin-detail/src/renderers/record-path.tsx → domain:ui + pm:queue, Bug (WCAG 2.2 SC 1.4.1 — travelled/upcoming/lost state conveyed by colour alone; the glyphs are aria-hidden). Fix shape: give each stage a programmatic state (visually-hidden text or aria-label), keep the glyphs decorative.


    Generated by Claude Code

  4. self-assigned this
    on Aug 24, 2026
  5. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    Claim: PM loop round 34
    Session: session_019ZyKZejBWZoCSj1NP35wcp
    Branch: claude/issue-5916-record-path-accessible-state
    Worktree: objectui-issue-5916
    Domain: domain:ui
    File surface: packages/plugin-detail/src/renderers/record-path.tsx (+ its tests, + the i18n keys the change needs, + a changeset) (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — adds accessible-name text and i18n keys; no authoring key changes and no published schema surface widens.
    Serial constraints cleared: packages/plugin-detail/src/renderers/record-path.tsx — no in-flight claim touches it; zero claude/issue-* branches on the remote.

    ⚠️ PREMISE CHECK FIRST — this file changed a few hours ago

    record-path.tsx was rewritten by PR #5917 (fix(plugin-detail): render record:path as a readout, not a fake segmented control, merged 2026-08-24), which closed a different card (#5768). This card was filed from inside that work and says so: "the readout markup was reworked in claude/issue-5768-inert-lifecycle-path, which de-buttoned the path but deliberately did not move its accessible semantics."

    ⛔ Do not take the card's JSX snippet as current source. Re-read the landed file first and establish, against the shipped markup, whether the defect still stands: are the ✓/✗ glyphs still aria-hidden, and is aria-current="step" still the only programmatic signal? premise_still_valid: false with no PR is a legitimate and valuable delivery here — report it rather than manufacturing a diff.

    If the premise holds

    The requirement is that a stage's state (travelled / upcoming / lost terminal) reaches the accessible name, not just colour and an aria-hidden glyph — WCAG 2.2 SC 1.4.1. The card names two shapes (visually-hidden per-stage text, or an aria-label composing label + state); pick one and say why.


    Generated by Claude Code

  6. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator
    {
      "issue": 5916,
      "status": "done",
      "branch": "claude/issue-5916-record-path-accessible-state",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5958",
      "premise_still_valid": true,
      "summary": "Premise re-checked against the SHIPPED file (0b12a3315, PR #5917) rather than the card's JSX snippet, and it holds unchanged: the check glyph is aria-hidden at record-path.tsx:177, the cross at :175, and aria-current='step' at :170 is still the only programmatic signal. #5917's data-stage-state / data-stage-terminal are test instruments, not accessibility — a data-* attribute is not in the accessibility tree, and its own header says so. Each stage now carries an accessible name composing its already picklist-localized label with its state, from five new detail.pathStage{Completed,Current,Upcoming,LostCurrent,LostUpcoming} keys added to all ten locale packs and mirrored byte-identically into DETAIL_DEFAULT_TRANSLATIONS. Shape chosen by measurement, not preference: listitem is not a name-from-contents role, so sr-only text inside a stage computes to an EMPTY accessible name (probed on this tree with the suite's own harness before writing the fix) — the card's first candidate shape would have satisfied a DOM assertion while delivering nothing to the accessibility tree, so the state is composed into aria-label instead. Glyphs stay decorative; the #5768 readout contract (role, aria-current, data-stage-*) is untouched. The ten packs plus the defaults map are inside the card's stated surface ('the i18n keys the change needs'): all-locales-key-parity fails a key en has that any pack lacks AND a key a pack has that en lacks, and defaults-maps-mirror-en-pack fails a map row whose key the pack lacks — five keys means fifty pack rows plus five map rows, or nothing.",
      "tests": "All at f4cb3fec4 (branch head), run from the repo root with pnpm exec vitest run per this repo's guard. SUITES: packages/plugin-detail whole package — 'Test Files 97 passed (97) / Tests 916 passed (916)'; packages/i18n/src/__tests__/ plus app-shell defaults-maps-mirror-en-pack.test.tsx — 'Test Files 52 passed (52) / Tests 893 passed (893)'; cross-package consumers of record:path (console x3, app-shell previews x1) — 'Test Files 4 passed (4) / Tests 129 passed (129)'. Two new suites add 13 cases. GATES (exit code captured by redirecting to a file first, never after a pipe; verdict lines quoted as each gate printed them): check:control-bytes 'OK (scanned 4942 tracked text file(s); skipped 85 binary)'; check:i18n-keys 'Every in-scope call-site key resolves against the en pack (2934 keys) ... every call site passes exactly the arguments that value has holes for' — this is what confirms { stage } matches the {{stage}} hole in all five keys; check:i18n-drift 'No en value changed in this range.' and '5 key(s) added, 0 removed'; check-changeset-presence.mjs '14 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'; turbo run type-check --filter=@object-ui/plugin-detail --filter=@object-ui/i18n — 14 tasks successful, and plugin-detail's script is 'tsc --noEmit' then 'tsc -p tsconfig.test.json', so the new suites are type-checked too; turbo run lint for both packages — 3 tasks successful, 0 errors (record-path.tsx reports 15 warnings and the merge-base version of the same file reports the same 15, so this change adds none). ABLATION: deleted the single aria-label line from renderStage and re-ran the two new suites plus the pre-existing record-path.inertReadout.test.tsx — 'Test Files 2 failed | 1 passed (3) / Tests 15 failed | 12 passed (27)', matching the direction predicted in both suite headers before running. Red: every case whose subject is the accessible name (group B 6, the three name-bearing cases of group C, group D 1, all five locale cases). Green: group A (the #5768 readout contract), group C's 'the cross stays aria-hidden' case, and the whole pre-existing inertReadout suite — none of which ever depended on the name. The mutation was confirmed ON DISK by grepping the deleted text (count 1 -> 0), not by the editor's exit code; the script carried trap ... EXIT INT TERM; the restore was verified byte-identical with diff -q against a pre-ablation copy and the marker count returned to 1. NO REBUILD claimed or needed: both suites import the subject relatively (../record-path) and resolve cross-package imports to each package's own src via the root vitest.config.mts alias table, so nothing resolves through a dist/ — the property record-path.inertReadout.test.tsx already records for itself. CI convergence not awaited, per the standing dispatch contract; gate jobs were still in_progress at report time (PR mergeable_state 'unstable').",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5956: record:path's three container aria-label literals are hardcoded English ('Record path' on both rows, 'Alternative terminal stages' on the lost group), and the last of those sits on a role-less div, so it is not merely untranslated but inert — browsers expose no accessible name on a generic element. Left untouched: an i18n defect rather than this card's colour-alone defect, and the div needs a decision rather than a string swap.",
        "filed as #5957: an unreached `won` terminus is still distinguished by hue alone (bg-emerald-500/30 vs bg-muted) and, after this change, still announces as a plain upcoming stage. Same WCAG 1.4.1 class as this card, but the fix is new user-facing copy in ten packs rather than a mechanical change, so it failed the bounded-in-place bar (the correct wording is pinned by nothing in the tree)."
      ]
    }

    Notes for the PM, outside the machine-checkable fields:

    • The card body is not truncated, but its JSX snippet is sanitizer-damaged — the span aria-hidden wrappers were eaten from the fenced block, leaving a bare check glyph and {stage.label}. Prose, repro and related-cards sections are all intact. The same sanitizer ate the tags out of my own PR body's evidence block on first write; I re-read it, rewrote that block without literal angle brackets, and confirmed the repair. Worth knowing that a fence is not protection.
    • File surface: no breach. 15 files — record-path.tsx, two new test files, useDetailTranslation.ts, ten locale packs, one changeset. The packs and the defaults map are the declared "i18n keys the change needs"; the parity gates make them non-optional, as the summary records.
    • PR is draft, no auto-merge, Fixes #5916 on the first line. No timers scheduled.

    Generated by Claude Code


    Generated by Claude Code

  7. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    os-dev-report

    Supersedes the previous comment: its os-dev-report HTML-comment marker did not survive storage — GitHub's sanitizer ate it, so that comment is invisible to a marker scan. Same report, marker as literal text. No edit-comment tool is reachable from this seat, hence a second comment rather than a fix in place.

    {
      "issue": 5916,
      "status": "done",
      "branch": "claude/issue-5916-record-path-accessible-state",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5958",
      "premise_still_valid": true,
      "summary": "Premise re-checked against the SHIPPED file (0b12a3315, PR #5917) rather than the card's JSX snippet, and it holds unchanged: the check glyph is aria-hidden at record-path.tsx:177, the cross at :175, and aria-current='step' at :170 is still the only programmatic signal. #5917's data-stage-state / data-stage-terminal are test instruments, not accessibility — a data-* attribute is not in the accessibility tree, and its own header says so. Each stage now carries an accessible name composing its already picklist-localized label with its state, from five new detail.pathStage{Completed,Current,Upcoming,LostCurrent,LostUpcoming} keys added to all ten locale packs and mirrored byte-identically into DETAIL_DEFAULT_TRANSLATIONS. Shape chosen by measurement, not preference: listitem is not a name-from-contents role, so sr-only text inside a stage computes to an EMPTY accessible name (probed on this tree with the suite's own harness before writing the fix) — the card's first candidate shape would have satisfied a DOM assertion while delivering nothing to the accessibility tree, so the state is composed into aria-label instead. Glyphs stay decorative; the #5768 readout contract (role, aria-current, data-stage-*) is untouched. The ten packs plus the defaults map are inside the card's stated surface ('the i18n keys the change needs'): all-locales-key-parity fails a key en has that any pack lacks AND a key a pack has that en lacks, and defaults-maps-mirror-en-pack fails a map row whose key the pack lacks — five keys means fifty pack rows plus five map rows, or nothing.",
      "tests": "All at f4cb3fec4 (branch head), run from the repo root with pnpm exec vitest run per this repo's guard. SUITES: packages/plugin-detail whole package — 'Test Files 97 passed (97) / Tests 916 passed (916)'; packages/i18n/src/__tests__/ plus app-shell defaults-maps-mirror-en-pack.test.tsx — 'Test Files 52 passed (52) / Tests 893 passed (893)'; cross-package consumers of record:path (console x3, app-shell previews x1) — 'Test Files 4 passed (4) / Tests 129 passed (129)'. Two new suites add 13 cases. GATES (exit code captured by redirecting to a file first, never after a pipe; verdict lines quoted as each gate printed them): check:control-bytes 'OK (scanned 4942 tracked text file(s); skipped 85 binary)'; check:i18n-keys 'Every in-scope call-site key resolves against the en pack (2934 keys) ... every call site passes exactly the arguments that value has holes for' — this is what confirms { stage } matches the {{stage}} hole in all five keys; check:i18n-drift 'No en value changed in this range.' and '5 key(s) added, 0 removed'; check-changeset-presence.mjs '14 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'; turbo run type-check --filter=@object-ui/plugin-detail --filter=@object-ui/i18n — 14 tasks successful, and plugin-detail's script is 'tsc --noEmit' then 'tsc -p tsconfig.test.json', so the new suites are type-checked too; turbo run lint for both packages — 3 tasks successful, 0 errors (record-path.tsx reports 15 warnings and the merge-base version of the same file reports the same 15, so this change adds none). ABLATION: deleted the single aria-label line from renderStage and re-ran the two new suites plus the pre-existing record-path.inertReadout.test.tsx — 'Test Files 2 failed | 1 passed (3) / Tests 15 failed | 12 passed (27)', matching the direction predicted in both suite headers before running. Red: every case whose subject is the accessible name (group B 6, the three name-bearing cases of group C, group D 1, all five locale cases). Green: group A (the #5768 readout contract), group C's 'the cross stays aria-hidden' case, and the whole pre-existing inertReadout suite — none of which ever depended on the name. The mutation was confirmed ON DISK by grepping the deleted text (count 1 to 0), not by the editor's exit code; the script carried trap ... EXIT INT TERM; the restore was verified byte-identical with diff -q against a pre-ablation copy and the marker count returned to 1. NO REBUILD claimed or needed: both suites import the subject relatively (../record-path) and resolve cross-package imports to each package's own src via the root vitest.config.mts alias table, so nothing resolves through a dist/ — the property record-path.inertReadout.test.tsx already records for itself. CI convergence not awaited, per the standing dispatch contract; gate jobs were still in_progress at report time (PR mergeable_state 'unstable').",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5956: record:path's three container aria-label literals are hardcoded English ('Record path' on both rows, 'Alternative terminal stages' on the lost group), and the last of those sits on a role-less div, so it is not merely untranslated but inert — browsers expose no accessible name on a generic element. Left untouched: an i18n defect rather than this card's colour-alone defect, and the div needs a decision rather than a string swap.",
        "filed as #5957: an unreached `won` terminus is still distinguished by hue alone (bg-emerald-500/30 vs bg-muted) and, after this change, still announces as a plain upcoming stage. Same WCAG 1.4.1 class as this card, but the fix is new user-facing copy in ten packs rather than a mechanical change, so it failed the bounded-in-place bar (the correct wording is pinned by nothing in the tree)."
      ]
    }

    Notes for the PM, outside the machine-checkable fields:

    • The card body is not truncated, but its JSX snippet is sanitizer-damaged — the span aria-hidden wrappers were eaten from the fenced block, leaving a bare check glyph and {stage.label}. Prose, repro and related-cards sections are all intact. The same sanitizer ate the tags out of my own PR body's evidence block on first write; I re-read it, rewrote that block without literal angle brackets, and confirmed the repair. A fence is not protection — and, as this comment shows, neither is an HTML comment.
    • File surface: no breach. 15 files — record-path.tsx, two new test files, useDetailTranslation.ts, ten locale packs, one changeset. The packs and the defaults map are the declared "i18n keys the change needs"; the parity gates make them non-optional, as the summary records.
    • PR is draft, no auto-merge, Fixes #5916 on the first line. No timers scheduled.

    Generated by Claude Code


    Generated by Claude Code

  8. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    ACCEPT — PR #5958, pending CI convergence

    Reviewed against GitHub, not against the report's self-account. Landing waits on the gate read (PR opened 04:37:07Z; window ~04:46–04:48Z).

    Checklist

    item reading
    PR form draft ✓, base main ✓, Fixes #5916 ✓
    Premise re-check done against the shipped file, not the card's snippet — see below
    Scope vs. declared fence 15 files / +506. Wider than it looks, and justified — verified independently below
    Changeset present, patch for @object-ui/plugin-detail + @object-ui/i18n
    #5569 untouched, as fenced ✓
    CI unstable — still converging, not yet read

    The premise check was the point of this dispatch, and it was done properly

    I briefed this card with an explicit falsification licence because record-path.tsx had been rewritten hours earlier by PR #5917. The dev re-read the landed file at 0b12a3315 and pinned each claim to a line: ✓ aria-hidden at :177, ✗ at :175, aria-current="step" at :170. Premise holds unchanged.

    It also caught the thing most likely to have produced a false "already fixed" reading: #5917 added data-stage-state / data-stage-terminal, which look like state now reaches assistive tech. They don't — a data-* attribute is not in the accessibility tree, and they are test instruments. The PR amends #5917's own header to say so, which stops the next reader making that mistake.

    The shape was chosen by measurement, and that measurement is the most valuable thing here

    The card offered two shapes and picked neither. I briefed "verify against the accessible tree, not the DOM." The dev probed it on this tree with the suite's own harness before writing the fix:

    • role="listitem" wrapping span.sr-only + label → accessible name ''
    • role="listitem" with aria-label="草稿, completed" → '草稿, completed'

    listitem is name-from-author only. So the visually-hidden shape — the card's first suggestion — would have satisfied a DOM assertion while delivering nothing to the accessibility tree. That is precisely the failure this card exists to prevent, and it was avoided by measuring rather than by picking the more familiar shape.

    Test design worth copying

    • Non-circular by construction. Selection is positional (index among listitems); assertion is by computed accessible name. The header explains why: with the fix reverted every name is '', so a name-keyed query would report "no such stage" rather than "wrong name."
    • Two independent non-vacuity guards. One asserts every stage has a name at all, so a later case can't compare '' to ''. The other renders en/zh/de and asserts the three names are distinct — if t() were bypassed, or every pack carried English, every other case would still pass and only this one fails.
    • The defect restated as a partition: 2 completed + 1 current + 3 upcoming = 6 stages. No stage ambiguous, none missed.
    • State follows the record, not the position — mounting on draft shows zero completed, proving the names are computed rather than baked into markup.
    • Both rows asserted. Desktop and mobile are both in the DOM (no CSS breakpoint in this env), so a regression reaching one viewport only would otherwise pass.
    • The glyphs stay decorative and that is tested — the fix adds a text equivalent rather than un-hiding the ✗.
    • The two-file split (provider vs provider-less) is justified by a real mechanism: createI18n registers itself as react-i18next's module-global default and survives cleanup(), so sharing a file would silently resolve against whichever locale mounted first. Precedent cited.

    Ablation separates signal from noise

    Deleting the single aria-label line → 15 red / 12 green, matching the direction predicted in both suite headers before running. Red: exactly the name-bearing cases. Green: group A (the #5768 readout contract), the "✗ stays aria-hidden" case, and the whole pre-existing inertReadout suite — none of which ever depended on the name. Mutation confirmed on disk by grep (1 → 0), restore verified byte-identical with diff -q.

    Scope — checked, not accepted on assertion

    15 files exceeds a naive reading of my fence, so I verified the justification rather than taking it: the repo has exactly ten locale packs (ar, de, en, es, fr, ja, ko, pt, ru, zh) and the PR touches all ten plus DETAIL_DEFAULT_TRANSLATIONS. With all-locales-key-parity failing in both directions and defaults-maps-mirror-en-pack failing a map row the pack lacks, five keys is fifty pack rows plus five map rows or nothing. That is mechanically forced, and it sits inside "the i18n keys the change needs" as briefed. No breach.

    Follow-ups filed — and one note for triage

    #5956 (hardcoded English container labels, one on a role-less div that names nothing) and #5957 (an unreached won terminus still distinguished by hue alone) are both filed unassigned with honest reasoning for not folding them in. #5957 is the more important one: it is the same WCAG 1.4.1 class as this card, on the one distinction this card left behind, and it is reachable without authors opting in (the WON_TOKENS heuristic). Declining it was right — the fix needs new user-facing copy in ten packs and nothing in the tree pins the wording — but it should not sit long.

    ⚠️ Label shapes across this round's three findings are inconsistent: #5954 carries finding + domain:ui, #5953 carries domain:ui only, #5957 carries no labels at all. All three remain visible to a triage sweep (bare cards are case ①, half-labelled are case ③), so nothing is lost — but flagging it for first touch. ⛔ Not grading them here.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions