Skip to content

fix(plugin-detail,i18n): localize record:path's container labels and announce its goal terminus - #6000

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-5956-record-path-a11y-residue
Aug 24, 2026
Merged

fix(plugin-detail,i18n): localize record:path's container labels and announce its goal terminus#6000
yinlianghui merged 2 commits into
mainfrom
claude/issue-5956-record-path-a11y-residue

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#5956
Fixes#5957

record:path finishes localizing and de-colouring its accessible names — the two residues #5916 named and deliberately left behind. One branch, two commits, one per card, each independently reviewable and independently verifiable.

#5916 is merged and is not touched here: its travelled / current / upcoming / lost-terminal names are unchanged, and its two suites still pass untouched. This builds on the seam it established (detail.pathStage*, record-path.stageStateAccessibleName.i18n.test.tsx).


Member A — the container labels (#5956)

Two defects wearing the same clothes, and they get two different fixes.

A1 — an English literal on a localized surface. 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, and that is asserted, not assumed.

A2 — an aria-label that named nothing. The lost-terminal alt group carried aria-label="Alternative terminal stages" on a bare div. A div has the generic role 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.

Decision: remove it rather than give the wrapper a role that takes a name. The card asked for this to be measured, so it was, on three counts:

  1. Nothing is lost. It was never announced, so removing it changes no user's experience. Naming the group, by contrast, is new verbosity on every traversal of the row.
  2. It would be redundant. After record:path conveys travelled / upcoming / lost stages by colour alone — the ✓ and ✗ are aria-hidden, so the state never reaches the accessible name #5916 every stage inside already announces closed lost in the session locale, in the one place role="list" can carry it. The forward/alt distinction is already in the accessible name; the group label would restate it.
  3. It would fork the two rows. The mobile row renders every stage in one flat list with no alt group at all, so a named group on desktop would make one control expose two different structures by viewport — against the invariant record-path.stageStateAccessibleName.i18n.test.tsx states in its own header ("both rows carry identical names").

The reasoning is recorded in the renderer beside the code, so the next reader does not re-litigate it.

Member B — the goal terminus (#5957)

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 #5916 already handled, on the one distinction it left behind. Reachable without authors opting in, since classify() finds won through the WON_TOKENS heuristic as well as an explicit terminal: 'won'; both routes are exercised.

New key detail.pathStageWonUpcoming{{stage}}, goal stage, not reached — in all ten packs. "Goal" is the file's own word for this stage (from the railClass comment) and the word both cards use, not new vocabulary.

Decision: one new key, not a pair — and that scope is a measurement of the stylesheet, not a preference. The defect is information carried by colour alone, so the name may only restate a distinction the colour actually makes:

stagerail classdistinct?verdict
upcoming + wonbg-emerald-500/30 vs bg-mutedyesgets a name
current + wonbg-primary — identical to any current stagenostays ordinary
completed + wonbg-emerald-500 — identical to any completed stagenostays ordinary

So a reached goal terminus keeps announcing as an ordinary current/completed stage. Naming it apart would hand a screen-reader user a distinction the screen does not make — the mirror image of the defect, and dead copy in ten packs besides. Because it is a decision rather than an omission, it is pinned by a test so it cannot drift into a fourth state unnoticed.

terminal is read from the same value renderStage hands railClass, so the name tracks the paint on each row by construction rather than via a second, drift-prone classification.


Verification

All gate output below is from a178b0237, the branch head and the final commit.

checkresult
pnpm exec vitest run packages/plugin-detail/ packages/i18n/Test Files 152 passed (152) / Tests 1825 passed (1825)
new + existing renderer suites, key parity, defaults-map mirrorTest Files 21 passed (21) / Tests 224 passed (224)
pnpm check:i18n-drift0 en value(s) changed (2 key(s) added, 0 removed)No en value changed in this range.
pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2936 keys)no call site carries a literal fallback beside itself
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 4959 tracked text file(s); skipped 85 binary).
type-check — plugin-detail, i18n, app-shellall three Done, exit 0 (app-shell after building its closure)
eslint . --no-inline-config (whole repo, 3624 files)0 errors in every file this PR touches; the 89 pre-existing errors are all in untouched files
pnpm --filter … lint (as CI invokes it)0 errors, exit 0

Reverse verification, both members, direction predicted before running.

  • Restoring the hardcoded 'Record path': 3 of 8 red — the zh, de/ja and non-vacuity cases — while the en, author-override and group-absence cases stay green, which is correct since none of them depend on the pack key.
  • Deleting the won branch: 4 of 8 red, including the load-bearing pair case.

The first won run turned up a bug in my own test, which is worth stating plainly. The pair case originally compared the two whole aria-label strings and passed with the fix reverted — vacuously, because 已成交 and 谈判中 differ regardless of state. It now compares the state half (the name with the stage's own label stripped), which fails as it should. The mutation and its restore were each confirmed on disk by grep, not by an editor exit code, and every mutation ran under a restore trap.

The 15 no-explicit-any warnings on the renderer are pre-existing: the any count in record-path.tsx is 15 at the merge base and 15 at head, and the only added lines matching any are prose plus the two rewritten aria-label lines that already carried as any.

Fence note

packages/plugin-detail/src/useDetailTranslation.ts is edited although the dispatch fence enumerated only the renderer, the packs, the renderer's tests and a changeset. Both call sites are bare t() with no inline defaultValue, so without a row in DETAIL_DEFAULT_TRANSLATIONS a provider-less host would render the raw key detail.pathLabel into the label. The shape is pinned by existing evidence sitting in the same file — #5916 put its own five detail.pathStage* rows in exactly this map — and defaults-maps-mirror-en-pack.test.tsx compares the map against the en pack key by key, so both new rows are now covered by it. Same defect class, same gate family, no other claim on the file.

Filed, not fixed here

#5998 — the desktop and mobile rows disagree about which stage is a won terminus (desktop restricts it to the last forward stage, mobile accepts any won-classified stage), so a mid-path 完成 paints and now announces two ways by viewport. The paint half predates this PR; deriving the name from the same value railClass consumes keeps each row self-consistent and leaves the underlying disagreement exactly as it was. Which row is right is a call about what the heuristic is for, so it is filed unassigned rather than folded in.


Generated by Claude Code

…inert group label
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.8 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-B8mvhDYH.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.14KB61.88KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 24, 2026 11:44
@yinlianghui
yinlianghui added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 8d3a529Aug 24, 2026
23 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-5956-record-path-a11y-residue branch August 24, 2026 11:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude