Skip to content

feat(menu): accessibility & localisation readiness sweep - #138

Merged
tarakanof merged 10 commits into
overhaul/ui-ng-2026-09from
feat/115-a11y-l10n
Sep 26, 2026
Merged

tarakanof merged 10 commits into
overhaul/ui-ng-2026-09from
feat/115-a11y-l10n

Conversation

@tarakanof

Copy link
Copy Markdown
Owner

Closes #115

Last sweep over the macOS app for accessibility and localisation, after #132/#134/#136/#137. Most of the groundwork was already in place: stock controls, text styles, chart descriptors, labelled canvases, and PhaseBadge pairing dot with text. This PR fills the gaps the audit turned up.

Accessibility

  • Menu-bar status item: MenuBarExtra forwards the label's accessibilityLabel to the NSStatusBarButton as AXTitle and drops accessibilityValue. That's why AXValue was empty on device. I confirmed it with an AX probe against a minimal MenuBarExtra. The label view now writes MenuRows.accessibilityValue onto the button (StatusItemAccessibility) whenever it changes. The same probe showed a value set on the button survives SwiftUI swapping the icon image. MENU-BOT.md is updated.
  • Reduce Motion: the Focus ring no longer animates to its new value. The bot already honoured it.
  • Differentiate Without Color: Clock health warnings (low battery, weak Wi-Fi, failed publish) were orange only. They now switch to a warning symbol, and VoiceOver says "Needs attention".
  • Increase Contrast: Settings preview captions (white at 60–85% on black) go near-solid.
  • VoiceOver:
    • Usage rows say "Claude, 5-hour window" and include the reset time.
    • Percent sliders report the percent, not the raw slider position.
    • Decorative degree signs are hidden.
  • Keyboard: Esc closes the Discover Clocks sheet. Done was already the default button, and the Restart confirmation already had a Cancel role.
  • Text scaling: there are no fixed font sizes in the app. The one fixed-width text frame (usage window label) is now flexible.

Localisation

  • Catalog: Localizable.xcstrings had only the 87 hand-added EmberKit keys. The app target's ~530 extracted strings had never been written back. It now has all 612 keys, synced with xcstringstool sync and kept in the repo's JSON format.
  • Translator comments: all 66 uncommented format strings (%@, %lld, inflected counts) now have one, including EmberKit keys that only had the generic note.
  • Plurals: "1 sessions", "1 active days", "after 1 rounds" and "next 1 hours" now use ^[…](inflect: true).
  • Percent style: "70 %" and "+5 %" are now "70%" and "+5%", like every other percent in the app.
  • Error strings: ValidationError (shown in the Connection pane) and the producer "plist not found" state were log-style Strings. They are now LocalizedStringResource sentences, and ValidationError is a LocalizedError.
  • US spelling: none of the extracted strings use British spelling. Code identifiers and comments were left alone.

Tooling / CI

  • scripts/strings.sh sync|check [DerivedData]:
    • sync merges the build's .stringsdata into the catalog, and adds EmberKit's strings (compiled with -emit-localized-strings, since Xcode never extracts a package's strings) as manual entries.
    • check fails when a key is missing or a format string has no comment.
  • The CI macOS job now builds into build/DerivedData and runs check. This is the "missing view string" test. A Swift test can't see the app target's strings, so it's a build step instead. RUNBOOK documents the workflow.

Audit checklist (in place of an XCUI audit)

performAccessibilityAudit would need a UI-test target and a launched app, which would re-register the producer LaunchAgents. Instead, every view was checked by hand for:

  • labels on icon-only buttons, canvases, the mirror, swatches, gauges and charts (descriptors)
  • colour-only state
  • animations
  • dialogs
  • fixed sizes

Not done

  • Info.plist usage descriptions aren't in an InfoPlist.xcstrings.
  • In glyph mode, the menu-bar icon still shows state by colour alone. The bot, the Monochrome option and the menu header cover it.
  • Firmware transition names and time/date previews stay verbatim, because they mirror what the clock shows.

Test evidence

  • swift test --package-path macos: 446 passed. New test: validationErrorsAreUserFacingSentences.
  • Unsigned Debug xcodebuild: succeeded with no new warnings.
  • scripts/strings.sh check: passes. Before the sync it listed the missing keys.
  • shellcheck scripts/strings.sh: clean.
  • Needs on-device check: run osascript -e 'tell application "System Events" to tell process "Ember" to get value of menu bar item 1 of menu bar 2'. It should return e.g. "Idle" or "Claude on m4 — Running". I didn't launch the Debug app here because doing so re-registers the producers.

MenuBarExtra hands the label view's accessibilityLabel to the
NSStatusBarButton (AXTitle "Ember") but drops accessibilityValue, so
VoiceOver never heard the bot's state: AXValue was empty on device.
Verified with an AX probe against a minimal MenuBarExtra: the label
arrives, the value doesn't, and a value set on the button directly
survives SwiftUI swapping the icon image every frame.

The label view now writes MenuRows.accessibilityValue onto the status
button whenever it changes, retrying briefly at launch before the
status bar window exists.

Refs #115
- The Focus ring always glided to its new value. With Reduce Motion on
  it now jumps.
- Clock health flagged low battery, weak Wi-Fi and a failed publish
  only by turning the cell orange. With Differentiate Without Color
  the cell's symbol becomes a warning triangle, and VoiceOver hears
  "Needs attention" either way.
- Settings' panel previews set their captions in 60-85% white on black;
  Increase Contrast now takes them to near-solid white.

Refs #115
- Usage rows read "Claude 5h, 47%": the label now says "5-hour window"
  and the value includes the reset time the row shows.
- Percent sliders (brightness, scroll speed, buzzer volume) reported the
  raw slider position; they now report the percent shown next to them,
  and the duplicate text is hidden.
- The coordinates' degree signs were separate VoiceOver stops.
- Esc now closes the Discover Clocks sheet, as Done does with Return.
- The usage window label had a fixed 20 pt width that clips longer
  translations.

Refs #115
…nces

ValidationError carried log-style English ("server URL must not be
empty", "plist not found") as a plain String, shown verbatim in the
Connection pane's token footer and in Agents, so it could never be
translated and read like a stack trace. The message is now a
LocalizedStringResource worded for the person reading it, the error is
a LocalizedError, and the one producer state string goes through
String(localized:).

Refs #115
"1 sessions", "1 active days", "after 1 rounds" and "the next 1 hours"
were plain interpolations. They now use automatic grammar agreement,
like the rest of the app, so English gets the singular and a
translation can supply its own plural forms. "70 %" and "+5 %" lose
the space, matching US style and every other percent the app formats.

Refs #115
The catalog held only the 87 EmberKit strings added by hand; the app
target's ~530 SwiftUI strings were extracted at build time but never
written back (Xcode only does that with the catalog open in the IDE),
so nothing but the menu rows could have been translated. It now holds
every extracted key, synced with xcstringstool, and every format
string (%@, %lld, inflected counts) carries a comment saying what its
arguments are, including the EmberKit ones that had a generic note.

Refs #115
Nothing noticed when a view gained a string the catalog lacked, which
is how it ended up with none of the app's strings. scripts/strings.sh
does from the command line what Xcode does with the catalog open:
`sync` merges the build's .stringsdata into Localizable.xcstrings (and
adds EmberKit's strings, which Xcode never extracts, from compiling the
package with -emit-localized-strings); `check` fails when a key is
missing or a format string has no translator comment. CI runs check on
its unsigned build's DerivedData.

Refs #115

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: #138 (a11y & l10n sweep, #115)

Verdict: OK to merge. Nothing blocking. Two should-fix items, both small.

What I verified locally (branch head, scratch worktree)

  • swift test --package-path macos: 446 passed.
  • Unsigned Debug xcodebuild -derivedDataPath build/DerivedData: succeeded, with no compiler warnings (just the usual appintents metadata note).
  • scripts/strings.sh check build/DerivedData: exit 0.
  • scripts/strings.sh sync build/DerivedData on the committed catalog left the file byte-identical, so the sync is idempotent and matches the repo's JSON format (" : " separators, sorted keys, non-ASCII kept as is).
  • Negative test for check: I deleted one app key and one EmberKit key and removed the comment from an inflected key. check exited 1 and printed all three problems plus how to fix them.
  • Catalog compared with the base branch: all 87 original keys are still there and none were removed. Existing plural variations (e.g. Today %lld sessions · %@) are untouched. The only changes to existing entries are 7 generic EmberKit comments replaced with specific ones, which is intended.
  • DEBUG leakage: the #Preview names in DashboardPreviews.swift are not in the catalog, and neither are DashboardRenderCounter or the fixtures. Those files contain no localizable calls.
  • Grepped for any count + plural noun without inflect/variations. The only hit is %lld ms, which is a unit and fine.
  • CI: all 3 checks pass.

Findings

  1. should-fix: SensorsSection percent is still hard-coded. Text("\(v, format: .number…sign(.always()))%") produces the key %@%%. "+5%" is correct for en, but the sign position is baked into the source, and this is the one percent in the app that doesn't go through FormatStyle.Percent (Percent.text, PercentSliderRow both do). Suggest Text((v / 100).formatted(.percent.precision(.fractionLength(0)).sign(strategy: .always()))) (or Text(v/100, format: …)). That drops the %@%% key, and fr/de/tr get their own spacing and order. AgentsPane's "Green under 70%, … 90%." is prose inside a localizable key, so leaving it literal is fine.
  2. should-fix: StatusItemAccessibility depends on the private class name NSStatusBarWindow and fails silently. If that name changes, setValue returns false 20 times (bounded at 5 s, good) and then gives up, and nothing logs it. That's the exact regression this PR fixes, and it would come back unnoticed. Either drop the class-name filter (walk every NSApp.windows content view for an NSStatusBarButton; Ember has one status item, so it's just as correct and cheap), or at least Logger/assertionFailure when the retries run out.
  3. nit: nobody has confirmed on a device that .task(id:) runs on a MenuBarExtra label and that the AXValue gets set. The PR lists this as a pending on-device check (the osascript line). Please run it before merging, since swift test and CI can't cover it.
  4. nit: check doesn't catch the reverse drift. Keys still in the catalog after their code is gone (stale app keys, manual EmberKit keys that no longer exist) pass silently, and sync never prunes manual keys. A warning list in check would do.
  5. nit: strings.sh without a DerivedData argument is fine. With a reused local DerivedData, though, find … -name '*.stringsdata' also picks up leftovers from files that have since been deleted, so sync can re-add dead keys. This doesn't affect CI, which starts from a fresh build/DerivedData. A RUNBOOK note ("pass a clean DerivedData") would cover it.
  6. nit: junk keys "", "—", "5h", "7d" are in the catalog with no comment or shouldTranslate. ""/"—" should be Text(verbatim:) or shouldTranslate: false. 5h/7d would be better with a comment ("abbreviation: 5-hour window").

Checked, no issues

  • ValidationError → LocalizedError + LocalizedStringResource. Callers (ConnectionPane, FeedError) now resolve it with String(localized:), and the new test pins the sentences.
  • The Reduce Motion ring, the Differentiate Without Color triangle with "Needs attention" in the AX value, and the Increase Contrast ink() are all correct and don't change layout in the default settings.
  • PercentSliderRow: VoiceOver now reads the value from the slider, and the duplicate text is hidden. Correct.
  • The usage label changed from .frame(width: 20) to minWidth: 20 + .fixedSize(). It still lays out at 20 pt for "5h"/"7d" and grows instead of truncating. The two rows only drift apart once a localized label is wider than 20 pt, which is acceptable.
  • .onExitCommand on Discover Clocks, and the hidden ° signs, are fine.
  • en plurals via ^[…](inflect: true) are correct, and each one has a translator comment.

…isses

StatusItemAccessibility looked for the button only inside a window of
the private class NSStatusBarWindow; if macOS renamed it, VoiceOver
would silently lose the menu-bar state again. It now walks every
window's view tree for an NSStatusBarButton (Ember has one status
item) and logs an error when the launch-time retries run out.

Refs #115
… keys

The humidity offset was the one percent built by hand ("+5%" baked
into a "%@%%" key); it now goes through the percent format style, so
the locale decides spacing and sign placement. "" and "—" were
catalog keys only because they were passed where a localized key is
expected; they're verbatim now. "5h"/"7d" get translator comments.

Refs #115
check only caught keys missing from the catalog, not the reverse, and
sync never deletes, so dead keys could pile up unnoticed. check now
lists them as warnings (they don't fail the build: removing one is a
translator-facing decision). RUNBOOK notes that sync wants a clean
DerivedData, since a reused one keeps deleted files' .stringsdata.

Refs #115
@tarakanof
tarakanof merged commit 94be9d9 into overhaul/ui-ng-2026-09 Sep 26, 2026
3 checks passed
@tarakanof
tarakanof deleted the feat/115-a11y-l10n branch September 26, 2026 11:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant