Skip to content

feat(android): resolve consumer locales against shipped translation bundles - #493

Merged
jkmassel merged 2 commits into
trunkfrom
jkmassel/locale-resolver-android
May 6, 2026
Merged

feat(android): resolve consumer locales against shipped translation bundles#493
jkmassel merged 2 commits into
trunkfrom
jkmassel/locale-resolver-android

Conversation

@jkmassel

@jkmassel jkmassel commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds an Android LocaleResolver and a new EditorConfiguration.Builder.setLocale(locale: Locale) method that resolves against a compile-time-generated set of shipped locales before storing the tag for serialization.
  • Drops the public setLocale(String?) overload. It is replaced by an internal setLocaleTag(String?) reserved for toBuilder round-trip and tests; external consumers must go through the Locale API.
  • The set of shipped locales is generated into a Kotlin internal object SupportedLocales at build time from the JS-side manifest, so a missing manifest fails the gradle build instead of silently falling through to English at runtime.
  • The shared Vite manifest plugin from Resolve consumer locales against shipped translation bundles #490 also lands here; the iOS sibling PR carries the same plugin. Whichever lands second is a no-op for that file.

Refs #490.

Root Cause

A locale string is a lossy encoding of what the system actually knows. Android hands the consumer a Locale — language, region, script, variant, extensions — and any boundary that flattens that to a string before the library decodes it throws data away. WP-Android's PerAppLocaleManager.getCurrentLocaleLanguageCode() returns Locale.getLanguage() — ISO 639-1 only — so a Brazilian user sends pt (matches the language-only pt bundle but not the regional Brazilian one), a Chinese user sends zh (no zh-cn/zh-tw match → English), and a user who picked en_GB / nl_BE / pt_BR in the system language picker loses their region variant before we ever reach GutenbergKit.

The library is the obvious place to decode a Locale into a wire-format tag, because the library is the only place that knows what bundles actually ship. Historically the supported list lived only in bin/prep-translations.js, so consumers had to mirror that table to do the right thing — and historically hadn't. Taking a Locale at the boundary also keeps signal the string would have dropped: the script subtag lets zh-Hant-HK resolve to zh-tw instead of English, and Android's legacy ISO 639-1 codes (iw for Hebrew, in for Indonesian) get aliased to the canonical bundle names before lookup.

Changes

Build

vite.config.js: New emitSupportedLocalesManifest plugin scans src/translations/ at build time and emits dist/supported-locales.json (sorted array of locale tags). The existing make copy-dist-android target ships it into the library's assets/.

android/Gutenberg/build.gradle.kts: New :Gutenberg:generateSupportedLocales gradle task reads the manifest from src/main/assets/supported-locales.json and emits an internal object SupportedLocales { val ALL: Set<String> } into build/generated/source/locales/main/. Wired into the main source set and runs ahead of every compile*Kotlin task. A missing manifest fails the build with a message pointing at make build, so the silent-fall-through-to-English failure mode is unreachable in shipped artifacts. Manifest parsing is type-checked — non-string entries fail the task with a clear message instead of silently shipping a nonsense locale.

.gitignore: The generated supported-locales.json is treated the same as the other build outputs in assets/ so it cannot be accidentally committed.

Resolution chain

For an input locale, normalised to lowercase with _-:

  1. Full tag (xx-yy) — match if shipped
  2. Script-implied region for macrolanguages we ship disjoint regional bundles for (e.g. zh-Hant-HKzh-tw, zh-Hanszh-cn)
  3. Language-only tag (xx) — match if shipped
  4. Fall back to en

Legacy ISO 639-1 codes that Android's Locale class still emits (iwhe, inid, nonb) are aliased to their canonical bundle names before lookup, so Hebrew/Indonesian/Norwegian users on devices reporting the legacy codes don't silently land on English.

Implemented in android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/LocaleResolver.kt as an internal class with a cached LocaleResolver.Default singleton the builder reuses. No runtime IO, no Context parameter.

API change

fun setLocale(locale: Locale)                     // new — runs resolution
internal fun setLocaleTag(locale: String?)        // toBuilder + tests only

The previously-public setLocale(String?) is removed. Consumers must pass a Locale value; the resolver decides the wire-format string. The internal setLocaleTag exists so toBuilder can round-trip a stored tag without re-running resolution.

Tests

  • Curated LocaleResolverTest covers the resolution chain (full-tag → script-implied region → language-only → en fallback, normalisation of pt_BR / EN_GB / etc., script subtags, legacy alias mapping).
  • A parameterised test asserts that every locale in the generated SupportedLocales.ALL resolves to itself — catches regressions where a locale gets added but the resolver mishandles it. Reads from the generated constant directly, so the test can never drift from what the resolver actually uses in production.
  • Builder-level integration test exercises setLocale(Locale) through to config.locale against the shipped manifest.

What We Explored

  • Hard-coding the supported set in native code — the footgun the issue calls out. Rejected.
  • Reading the manifest from assets/ at runtime — the original direction. Worked, but had three problems collapsed into one fix by the build-time generator: a missing manifest silently degraded every consumer to English with no log signal; the builder did a disk read on every setLocale call (StrictMode-visible on the main thread); and the API needed a Context parameter purely to reach assets/. Generating a Kotlin constant at build time fails the build if the manifest is missing, removes the runtime IO entirely, and lets setLocale take just a Locale.
  • Reading the locale list from filenames in assets/ — Vite hashes the chunk filenames (pt-br-UCkBcRdR.js) and prefixes can collide (nl-be-... vs nl-...), so a parser would have to re-encode the SUPPORTED_LOCALES list anyway. A manifest is simpler and unambiguous.
  • A JS-side mirror resolver — initially this PR carried one to defend the JS load path against opaque locale strings. With the native side now resolving to a canonical tag before the value reaches the editor, the JS resolver was redundant duplication — and during review, a divergence had already crept in (JS treated zh-Hans-CN as language-only zh while the native side correctly collapsed it to zh-cn). The JS load path now trusts the native resolution result and falls back to English for anything not in the static glob.

Behaviour change

Input Before After
`setLocale(Locale("pt", "BR"))` (didn't exist) `pt-br` ✅
`setLocale(Locale("zh", "CN"))` (didn't exist) `zh-cn` ✅
`setLocale(Locale("nl", "BE"))` (didn't exist) `nl-be` ✅
`setLocale(Locale("fr", "CA"))` (didn't exist) `fr` ✅ (regional bundle absent → language fallback)
`setLocale(Locale.forLanguageTag("zh-Hant-HK"))` (didn't exist) `zh-tw` ✅ (script-implied region)
`setLocale(Locale("iw", "IL"))` (didn't exist) `he` ✅ (legacy alias)
`setLocale("pt_BR")` (was opaque) `pt_BR` (no match → English) does not compile
`Locale.getLanguage()` returning `zh` (existing path) English English (unchanged — no `zh` bundle; consumers passing the full `Locale` now get `zh-cn`)

Removing the string overload is a breaking change for any caller that was passing a raw string. The migration is mechanical (`setLocale("pt_BR")` → `setLocale(Locale.forLanguageTag("pt-BR"))`).

Test plan

  • `./android/gradlew :Gutenberg:testDebugUnitTest --tests "org.wordpress.gutenberg.model.LocaleResolverTest"` — curated + 49-locale exhaustive, all pass
  • `./android/gradlew :Gutenberg:testDebugUnitTest --tests "org.wordpress.gutenberg.model.EditorConfigurationBuilderTest"` — existing suite still green (callsites that passed strings updated to `setLocaleTag`)
  • `./android/gradlew detekt` — clean
  • Deleting `src/main/assets/supported-locales.json` and re-running `:Gutenberg:generateSupportedLocales` fails with the expected "run `make build`" message
  • `npm run lint:js` on the changed files — clean

Out of scope

  • Changing the `SUPPORTED_LOCALES` list itself.
  • Changing the wire format of `EditorConfiguration.locale` — still an opaque string on the JS side.
  • Pluralization / RTL handling — separate concerns.

Related

@github-actions github-actions Bot added the [Type] Enhancement A suggestion for improvement. label May 5, 2026
@jkmassel
jkmassel force-pushed the jkmassel/locale-resolver-android branch 3 times, most recently from ba9eaed to a40dbff Compare May 5, 2026 20:14
Adds a Vite build plugin that scans `src/translations/` and emits
`dist/supported-locales.json` — a sorted array of the locale tags we
actually ship. The native iOS and Android sides consume this so the
"what do we ship?" answer has exactly one source of truth.

Also switches `loadTranslations` from a dynamic `import()` (which threw
on a missing locale and fell back to English from the catch) to an
`import.meta.glob` lookup that returns early with a warning when the
tag isn't in the static map. Same exact-match-or-English behaviour, but
the loader map is enumerable at build time so the failure mode is
explicit rather than catch-driven.

The native side now resolves consumer-supplied locales to a shipped tag
before the value reaches JS, so the JS load path doesn't need its own
resolver — anything not in the static glob is a bug upstream and falls
back to English with a warn().
@jkmassel
jkmassel force-pushed the jkmassel/locale-resolver-android branch from a40dbff to 7863e1d Compare May 5, 2026 20:16
@jkmassel
jkmassel requested a review from oguzkocer May 5, 2026 20:17
@jkmassel
jkmassel marked this pull request as ready for review May 5, 2026 20:17
@jkmassel
jkmassel force-pushed the jkmassel/locale-resolver-android branch 4 times, most recently from a262058 to d5eaa76 Compare May 5, 2026 20:49
…undles

Adds an Android `LocaleResolver` and a new
`EditorConfiguration.Builder.setLocale(locale: Locale)` that resolves
against a compile-time-generated set of shipped locales before storing
the tag for serialization. The previously-public `setLocale(String?)`
overload is removed; an `internal` `@JvmSynthetic setLocaleTag(String?)`
is reserved for `toBuilder` round-trip and tests.

The set of shipped locales is generated into a Kotlin
`internal object SupportedLocales` at build time from the JS-side
manifest, so a missing manifest fails the gradle build instead of
silently falling through to English at runtime. The Gradle task uses
`JsonSlurper` to parse the manifest and validates each entry is a
string; non-string entries fail the task with a clear message.

## Resolution chain

For an input locale, normalised to lowercase with `_` → `-`:

1. Full tag (`xx-yy`) — match if shipped
2. Script-implied region for macrolanguages we ship disjoint regional
   bundles for (e.g. `zh-Hant-HK` → `zh-tw`, `zh-Hans` → `zh-cn`)
3. Language-only tag (`xx`) — match if shipped
4. Fall back to `en`

Inputs are parsed as BCP-47 via `Locale.forLanguageTag`, so script-
tagged inputs like `zh-Hans-CN` collapse to `zh-cn` rather than falling
through to English. Variant and Unicode-extension subtags (e.g.
`de-DE-u-ca-gregory`) are ignored — the editor doesn't vary
translations by calendar or numbering system.

Legacy ISO 639-1 codes that Android's `Locale` class still emits
(`iw` → `he`, `in` → `id`, `no` → `nb`) are aliased to canonical
bundle names before lookup, so Hebrew/Indonesian/Norwegian users on
devices reporting the legacy codes don't silently land on English.

## Why a Locale and not a String

A locale string is a lossy encoding of what the system actually knows.
Android hands the consumer a `Locale` — language, region, script,
variant, extensions — and any boundary that flattens that to a string
before the library decodes it throws data away. Taking a `Locale` at
the boundary keeps signal the string would have dropped: the script
subtag lets `zh-Hant-HK` resolve to `zh-tw` instead of English, and
Android's legacy ISO 639-1 codes get aliased to the canonical bundle
names before lookup.

## Tests

- Curated `LocaleResolverTest` covers the resolution chain (full-tag →
  script-implied region → language-only → `en` fallback, normalisation
  of `pt_BR` / `EN_GB` / etc., script subtags, legacy alias mapping).
- A parameterised test asserts that every locale in the generated
  `SupportedLocales.ALL` resolves to itself — catches regressions
  where a locale gets added but the resolver mishandles it. Reads from
  the generated constant directly, so the test can never drift from
  what the resolver actually uses in production.
- Builder-level integration test exercises `setLocale(Locale)` through
  to `config.locale` against the shipped manifest.

`make test-android` now depends on `make build` so the manifest is
populated before the exhaustive test runs.

Refs #490.
@jkmassel
jkmassel force-pushed the jkmassel/locale-resolver-android branch from d5eaa76 to 5d2d4ac Compare May 5, 2026 21:05
jkmassel added a commit that referenced this pull request May 5, 2026
Bring the iOS resolver up to parity with the Android sibling (PR #493) and
align the JS load path with the "native is canonical" decision made there.

Resolver: parse the input as `Locale` and use `language.languageCode`,
`region`, and `language.script` instead of splitting strings. Adds two
chain steps the original PR didn't carry — script-implied region
(`zh-Hant-HK` → `zh-tw`, `zh-Hans` → `zh-cn`) and legacy ISO 639-1 alias
mapping (`iw` → `he`, `in` → `id`, `no` → `nb`). Foundation already
canonicalises the legacy codes when parsing identifiers, so the alias map
is defense-in-depth on iOS, but keeps behaviour symmetric with Android.

One iOS-specific behaviour: Foundation supplies an implicit script for
bare-language tags (`zh` → `Hans`, `ja` → `Jpan`), so a bare `zh` lands
on `zh-cn` via the script-implied step instead of falling through to
English. Android's `Locale.forLanguageTag` leaves the script unset, so
the same bare tag falls through there.

JS: drop the JS-side `resolveLocale` and the matching test. The native
resolver is now the single source of truth for what the loader sees;
`loadTranslations` falls back to English for anything not in the static
glob. Removes the supported-locales filter from the Vite plugin since the
manifest is already excluded from `src/translations/`.

Tests: extend `LocaleResolverTests` to cover the new chain steps,
including `zh-Hant-MO` and the bare `zh-Hans`/`zh-Hant` cases.
@jkmassel
jkmassel merged commit e9cbc55 into trunk May 6, 2026
22 checks passed
@jkmassel
jkmassel deleted the jkmassel/locale-resolver-android branch May 6, 2026 03:11
dcalhoun added a commit that referenced this pull request Jul 27, 2026
The demo app never called `setLocale`, so the editor always ran in
English regardless of the scheme's *App Language* setting. Testing a
translation meant editing code.

Resolve the launch language against the locales the editor ships and
pass the result through `applyDemoAppDefaults`, the single funnel every
demo configuration already flows through.

Read `Locale.preferredLanguages` rather than
`Bundle.main.preferredLocalizations`. The latter filters against the
localizations the app bundle itself ships, and the demo app ships only
English, so every selection would collapse to `en`.

Surface the resolved locale in the configuration details, noting the
requested language when it differs. A language with no shipped bundle
renders in English, which is otherwise indistinguishable from the
selection being ignored.

Xcode's right-to-left pseudolanguages are explicitly not supported. They
are layout overrides rather than languages — Xcode passes
`-AppleTextDirection YES -NSForceRightToLeftWritingDirection YES` with no
`-AppleLanguages` — so UIKit mirrors the surrounding app while the editor,
which renders in a web view and keys off the locale, does not. Testing
right-to-left rendering means selecting a real language such as Arabic or
Hebrew.

`DemoAppLocale` duplicates the resolution chain that #492 adds to the
library as `LocaleResolver`, matching Android's already merged in #493.
It is scoped to one file so reviving #492 removes it wholesale, leaving
a one-line call change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dcalhoun added a commit that referenced this pull request Jul 30, 2026
* feat: derive and apply locale text direction

The editor renders every locale left-to-right. `src/index.html` hardcodes
`lang="en"` with no `dir`, and nothing sets them at runtime, so the four
right-to-left bundles we ship (`ar`, `fa`, `he`, `ur`) get English
phonetics from screen readers and an LTR layout.

In WordPress, core establishes this: it renders `<html lang dir>`,
`<body class="rtl">`, and populates the `text direction` string backing
`isRTL()`. GutenbergKit loads a static `index.html`, so nothing plays
that role.

Derive direction from the resolved locale — a fixed property of the
language, already resolved to a shipped tag before it reaches JS — and
apply it to both the document and `@wordpress/i18n`. The `setLocaleData`
entry matters independently of CSS: components across `components`,
`block-editor`, `block-library`, and `editor` call `isRTL()` at runtime
to pick icons, accessibility labels, keyboard navigation, and drop-zone
geometry.

The string is injected rather than read from the bundle because the
`wp-plugins/gutenberg` GlotPress project doesn't carry it — it belongs
to core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: load stylesheets matching the editor text direction

The editor imported only the left-to-right Gutenberg stylesheets, so
right-to-left locales rendered translated strings in a mirrored layout:
logical properties resolved backwards, and rules Gutenberg guards with
`body.rtl` or `html[dir=rtl]` never matched.

Import both variants and inject one at runtime. The `-rtl` bundles are
full rewrites rather than overrides — across the five editor stylesheets
roughly 690 selectors appear in both files with conflicting declarations
and almost no `[dir=rtl]` scoping — so loading both would let source
order decide the direction every user gets. WordPress swaps the enqueued
file server-side; the equivalent choice happens here because the editor
loads a single static `index.html`.

Both variants ship in the bundle rather than being fetched on demand.
The assets are already bundled into the host app, so the added weight
costs no network time, and selecting synchronously avoids introducing
async work before first paint.

`default-editor-styles.css` is left alone: it and its `-rtl` sibling are
byte-identical, containing nothing directional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(demo-ios): forward Xcode's App Language to the editor

The demo app never called `setLocale`, so the editor always ran in
English regardless of the scheme's *App Language* setting. Testing a
translation meant editing code.

Resolve the launch language against the locales the editor ships and
pass the result through `applyDemoAppDefaults`, the single funnel every
demo configuration already flows through.

Read `Locale.preferredLanguages` rather than
`Bundle.main.preferredLocalizations`. The latter filters against the
localizations the app bundle itself ships, and the demo app ships only
English, so every selection would collapse to `en`.

Surface the resolved locale in the configuration details, noting the
requested language when it differs. A language with no shipped bundle
renders in English, which is otherwise indistinguishable from the
selection being ignored.

Xcode's right-to-left pseudolanguages are explicitly not supported. They
are layout overrides rather than languages — Xcode passes
`-AppleTextDirection YES -NSForceRightToLeftWritingDirection YES` with no
`-AppleLanguages` — so UIKit mirrors the surrounding app while the editor,
which renders in a web view and keys off the locale, does not. Testing
right-to-left rendering means selecting a real language such as Arabic or
Hebrew.

`DemoAppLocale` duplicates the resolution chain that #492 adds to the
library as `LocaleResolver`, matching Android's already merged in #493.
It is scoped to one file so reviving #492 removes it wholesale, leaving
a one-line call change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: Apply border styles for RTL layouts

Absolutely targeting the right border resulted in unexpected styling for
RTL language layout.

* feat(demo-android): forward the per-app language to the editor

The demo app never called `setLocale`, so the editor always ran in
English regardless of the device or per-app language. Testing a
translation — or right-to-left rendering — meant editing code.

Declare a `localeConfig` so the app appears in the system's per-app
language picker, read the selection, and pass it to both configuration
paths. No resolution logic is needed on this side:
`EditorConfiguration.Builder.setLocale(Locale)` already resolves against
the bundled translations via the library's `LocaleResolver`.

The selection is read from the platform's `LocaleManager` rather than
`AppCompatDelegate.getApplicationLocales()`. That helper resolves the
application locale by walking appcompat's registry of live activity
delegates, and every activity in this app extends `ComponentActivity`
rather than `AppCompatActivity`, so the registry is always empty and the
helper reports no selection regardless of what the system holds.

The configuration is rebuilt when the locale changes. Android recreates
the activity on a locale change, but the view model survives it, so
loading the configuration from `LaunchedEffect(Unit)` would keep serving
the one built with the previous locale.

The offered languages are a curated subset rather than all ~49 shipped
locales, each covering a distinct rendering path: `ar` for right-to-left
with cursive shaping, `he` for right-to-left without it, `ja` for CJK
glyph selection and line breaking, `pt-BR` for the resolver's regional
step, and `en`/`es`/`fr` as Latin baselines.

Surface the resolved locale in the configuration details, linking to the
system picker. A language with no bundled translations resolves to `en`,
which is otherwise indistinguishable from the selection being ignored.
The link is omitted below Android 13, which has no per-app language
screen to open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: Update asymmetric properties for RTL language layouts

Avoid absolute direction styles that break RTL language layouts.

* fix: Remove unused styles

The relevant UI element no longer exists.

* fix(demo-android): refresh the locale when returning from the picker

Tapping "Change" and selecting a language left the Editor Configuration
screen showing the previous locale. Backing out and re-entering was
required to see the new one.

The locale was read once during composition. Changing the per-app
language does not necessarily recreate the activity — the system picker
belongs to another task, so this activity is merely stopped and resumed —
and nothing prompted the composition to re-read the value on return.

Re-read the locale on `ON_RESUME` and drive the configuration reload from
that state, so both paths are covered: activity recreation where it
happens, and a plain resume where it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: Reduce comment verbosity

Remove comments deemed unnecessary.

* refactor(demo-ios): move DemoAppLocale into Services

The type reads platform state and resolves it to a locale — the same
kind of thing as the other members of `Services`. It was placed under
`Views` only because that group is file-system synchronized, so files
added there compile without editing `project.pbxproj`.

Drop the note explaining that placement, which no longer applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ios): declare the editor's language to assistive technology

VoiceOver read the native block inserter with the host app's default
speech voice, so an Italian block list was announced by an English
engine. The web content sets `documentElement.lang`, which VoiceOver
honors, but the native surfaces are a separate accessibility tree that
declared nothing and fell back to the app's language.

Set `accessibilityLanguage` on the inserter's hosting view, and on the
camera and patterns sheets. `accessibilityLanguage` is inherited down a
view hierarchy, but a sheet is presented as a sibling rather than a
descendant, so each presentation boundary has to declare it. The locale
travels through the SwiftUI environment, which does cross sheets.

SwiftUI has no equivalent modifier — `accessibilityLanguage` exists only
on `UIView` and `UIAccessibilityElement` — so `editorAccessibilityLanguage()`
bridges to UIKit through a representable.

Strings the host supplies rather than the editor are covered too. A host
localizes itself to the same locale it passes to `setLocale`, so its
strings are in that language; where they are not, the host is shipping
untranslated strings and the library should not model around it.

The system photo picker renders out of process and owns its own
accessibility tree, so it cannot be annotated from here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(demo-ios): stop English falling through to the next preferred language

With English first and French second in iOS's preferred languages, the
editor loaded French. With English alone it loaded English.

English is the editor's source language, so no `en` bundle ships and the
lookup cannot match it. The resolver treated that miss as "this language
is unavailable, try the next one" and moved on to French — but the user's
first choice was English, and English is exactly what the editor renders
without a bundle. It only appeared correct with a single preferred
language because the loop then ran out and hit the same default.

Stop the search on any English tag. Regional variants that do ship —
`en-gb`, `en-au` — still match before that check, and genuinely
unshipped languages still fall through.

Describe the outcome accurately too: "fr — resolved from en-US" implied
`en-US` legitimately maps to French, and "en — no bundle, using default
from en-US" would still misdescribe asking for English and getting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ios): guard EditorAccessibilityLanguage behind canImport(UIKit)

`swift test` builds the package for the host platform, where UIKit is
unavailable, so the `UIViewRepresentable` bridge failed to compile and
took the library test suite with it.

Wrap the file in `#if canImport(UIKit)`, matching the sibling views. Both
call sites already sit inside the same guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: inject the editor styles before the existing stylesheets

The stylesheets moved from a side-effect import to a runtime injection so the
direction variant can be chosen at load. Vite hoisted the import ahead of
`index.scss`, while appending places it after the stylesheet link.

These stylesheets are the base layer GutenbergKit's own styles build on, and
several selectors tie on specificity across the two, which the cascade then
resolves by source order. Insert before the first stylesheet to preserve the
order the import produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: correct the toolbar scroll indicators for RTL layouts

In a right-to-left container `scrollLeft` is `0` at the right edge and grows
negative moving left, so comparing it directly against zero left the left
gradient permanently hidden and the right gradient shown even at the start
edge.

Normalize the offset to a distance from the start, then map it onto the
physical edges the gradients are anchored to, which do not flip with the
writing direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: derive the editor direction from a single resolution

`configureLocale` resolved the text direction to set on the document, then
`setUpEditorEnvironment` resolved it a second time from the raw
`getGBKit().locale` to choose the stylesheets. The two agree today only
because `isRTLLocale(undefined)` happens to match the `en` default, so a
different default or a normalized locale value would leave the chrome and
canvas stylesheets disagreeing on direction.

Return the resolved direction from `configureLocale` and pass it through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: avoid resolving computed style on every toolbar scroll

`updateScrollState` is the scroll listener, so reading the container's
computed `direction` forced a style resolution on every frame of a
touch-dragged toolbar. The direction is set once at startup and fixed for
the editor's lifetime.

Read `documentElement.dir` instead, matching how the visual editor already
selects its stylesheets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ios): apply the accessibility language once the view is in a hierarchy

`updateUIView` runs before the representable's view is necessarily attached
to a superview, and with no superview the walk up the responder chain finds
no hosting controller. The language never changes afterward, so SwiftUI had
no reason to call `updateUIView` again and the miss was permanent —
VoiceOver would read localized strings with the device voice.

Re-apply on move to a window so the assignment retries once the hierarchy
exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(demo-android): guard the per-app language picker against no handler

`ACTION_APP_LOCALE_SETTINGS` is optional even on API 33+ and some devices
ship no handler for it, so gating the row on the SDK level alone meant
tapping "Editor Locale" threw an uncaught `ActivityNotFoundException`.

Resolve the intent up front and fall back to the plain read-only row.
Also drop a local left unused by the resume-aware locale read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(demo-ios): match English by language subtag rather than prefix

`hasPrefix("en")` matches any tag starting with those two letters, such as
`enm` for Middle English, rather than the English language subtag.

Reuse `DemoAppLocale.isEnglish`, which already parses the subtag for the
same purpose during resolution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ios): declare the accessibility language once on the editor

`accessibilityLanguage` was applied at every presentation boundary: an
imperative assignment on the block inserter's hosting controller, and an
`editorAccessibilityLanguage()` modifier on the sheets presented from it.
The modifier existed because modal presentations are siblings of their
presenter rather than descendants, which was assumed to break inheritance.

Testing showed it does not. With the app running in English and the editor
locale pinned to French, a single assignment on `EditorViewController.view`
gives a matching speech voice on the editor chrome, the native inserter, the
patterns sheet, and the camera sheet — identical to annotating each boundary,
and confirmed against WordPress-iOS.

Removes `EditorAccessibilityLanguage.swift` along with the `UIViewRepresentable`
shim, its responder-chain walk, and the `didMoveToWindow` retry that existed
only to work around the shim's timing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants