From 13edbc0870a925e8ce42ce85e0b9e074423dcadf Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 11:05:52 -0400 Subject: [PATCH 01/21] feat: derive and apply locale text direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``, ``, 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) --- src/utils/localization.js | 75 +++++++++++++++++++ src/utils/localization.test.js | 129 +++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/utils/localization.test.js diff --git a/src/utils/localization.js b/src/utils/localization.js index 9595c4b27..2d81e3228 100644 --- a/src/utils/localization.js +++ b/src/utils/localization.js @@ -15,6 +15,22 @@ const DEFAULT_LOCALE = 'en'; // loader map below is always in sync with what we actually ship. const TRANSLATION_MODULES = import.meta.glob( '../translations/*.json' ); +// Right-to-left locales among the bundles we ship. Direction is a fixed +// property of a language, and the native side has already resolved the +// consumer-supplied locale to one of these tags before it reaches JS, so +// deriving direction here always agrees with the translations we load. +// +// Kept as base language tags: no regional bundle we ship (`ar`, `fa`, `he`, +// `ur` have none) splits across directions, and matching on the base tag +// keeps this correct if a regional RTL bundle is added later. +const RTL_LOCALES = new Set( [ 'ar', 'fa', 'he', 'ur' ] ); + +// The key `@wordpress/i18n` reads for `isRTL()`, which resolves to +// `_x( 'ltr', 'text direction' )`. The `\u0004` escape is the gettext +// context separator joining a string's context to its msgid; written as an +// escape so the control character stays visible in source. +const TEXT_DIRECTION_KEY = 'text direction\u0004ltr'; + /** * Initializes i18n support for the editor. * @@ -23,6 +39,65 @@ const TRANSLATION_MODULES = import.meta.glob( '../translations/*.json' ); export async function configureLocale() { const { locale = DEFAULT_LOCALE } = getGBKit(); await loadTranslations( locale ); + configureTextDirection( locale ); +} + +/** + * Determines whether a locale is written right-to-left. + * + * @param {string} locale The locale to check. + * + * @return {boolean} Whether the locale is right-to-left. + */ +export function isRTLLocale( locale ) { + if ( ! locale ) { + return false; + } + + // Match on the base language subtag so regional variants (e.g. `ar-dz`) + // resolve correctly even though we don't currently ship any. + const [ language ] = locale.toLowerCase().split( /[-_]/ ); + return RTL_LOCALES.has( language ); +} + +/** + * Applies the locale's text direction to the document and to `@wordpress/i18n`. + * + * In WordPress, core renders `` and ``, and + * populates the `text direction` string that backs `isRTL()`. GutenbergKit + * loads a static `index.html`, so nothing performs that role and the editor + * would otherwise render every locale as English left-to-right. + * + * Both halves matter. The DOM attributes drive CSS logical properties, bidi + * text runs, and native spellcheck/screen-reader behavior. The `setLocaleData` + * entry drives `isRTL()`, which Gutenberg components call at runtime to pick + * icons, accessibility labels, keyboard navigation, and drop-zone geometry — + * none of which CSS can correct. + * + * The translation bundles we ship come from the `wp-plugins/gutenberg` GlotPress + * project, which does not carry the `text direction` string (it belongs to + * core), so the entry is injected here rather than read from the bundle. + * + * @param {string} locale The locale in use. + * + * @return {void} + */ +function configureTextDirection( locale ) { + const isRTL = isRTLLocale( locale ); + const direction = isRTL ? 'rtl' : 'ltr'; + + // Back `isRTL()` for Gutenberg's runtime direction checks. + setLocaleData( { [ TEXT_DIRECTION_KEY ]: [ direction ] } ); + + const { documentElement, body } = document; + + documentElement.lang = locale; + documentElement.dir = direction; + + // Some Gutenberg styles key off `body.rtl` rather than `[dir=rtl]`. + body?.classList.toggle( 'rtl', isRTL ); + + debug( `Text direction configured as "${ direction }" for "${ locale }"` ); } /** diff --git a/src/utils/localization.test.js b/src/utils/localization.test.js new file mode 100644 index 000000000..279108e39 --- /dev/null +++ b/src/utils/localization.test.js @@ -0,0 +1,129 @@ +/** + * External dependencies + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * WordPress dependencies + */ +import { setLocaleData } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { configureLocale, isRTLLocale } from './localization'; +import { getGBKit } from './bridge'; + +vi.mock( './bridge' ); +vi.mock( './logger' ); + +vi.mock( '@wordpress/i18n', () => ( { + setLocaleData: vi.fn(), +} ) ); + +// The gettext context separator joining a string's context to its msgid. +const TEXT_DIRECTION_KEY = `text direction${ String.fromCharCode( 4 ) }ltr`; + +describe( 'isRTLLocale', () => { + it.each( [ 'ar', 'fa', 'he', 'ur' ] )( + 'identifies %s as right-to-left', + ( locale ) => { + expect( isRTLLocale( locale ) ).toBe( true ); + } + ); + + it.each( [ 'en', 'fr', 'ja', 'pt-br', 'zh-cn', 'nl-be' ] )( + 'identifies %s as left-to-right', + ( locale ) => { + expect( isRTLLocale( locale ) ).toBe( false ); + } + ); + + it( 'matches on the base language subtag for regional variants', () => { + // No regional RTL bundle ships today, but direction is a property of + // the language, so a future `ar-dz` bundle must not regress to LTR. + expect( isRTLLocale( 'ar-dz' ) ).toBe( true ); + expect( isRTLLocale( 'ar_DZ' ) ).toBe( true ); + } ); + + it( 'is case insensitive', () => { + expect( isRTLLocale( 'AR' ) ).toBe( true ); + expect( isRTLLocale( 'He' ) ).toBe( true ); + } ); + + it( 'treats missing locales as left-to-right', () => { + expect( isRTLLocale( undefined ) ).toBe( false ); + expect( isRTLLocale( '' ) ).toBe( false ); + } ); +} ); + +describe( 'configureLocale', () => { + beforeEach( () => { + vi.clearAllMocks(); + document.documentElement.removeAttribute( 'lang' ); + document.documentElement.removeAttribute( 'dir' ); + document.body.classList.remove( 'rtl' ); + } ); + + it( 'applies right-to-left direction for an RTL locale', async () => { + getGBKit.mockReturnValue( { locale: 'ar' } ); + + await configureLocale(); + + expect( document.documentElement.dir ).toBe( 'rtl' ); + expect( document.documentElement.lang ).toBe( 'ar' ); + expect( document.body.classList.contains( 'rtl' ) ).toBe( true ); + } ); + + it( 'applies left-to-right direction for an LTR locale', async () => { + getGBKit.mockReturnValue( { locale: 'fr' } ); + + await configureLocale(); + + expect( document.documentElement.dir ).toBe( 'ltr' ); + expect( document.documentElement.lang ).toBe( 'fr' ); + expect( document.body.classList.contains( 'rtl' ) ).toBe( false ); + } ); + + it( 'defaults to English left-to-right when no locale is provided', async () => { + getGBKit.mockReturnValue( {} ); + + await configureLocale(); + + expect( document.documentElement.dir ).toBe( 'ltr' ); + expect( document.documentElement.lang ).toBe( 'en' ); + } ); + + it( 'removes a stale rtl body class when switching to an LTR locale', async () => { + document.body.classList.add( 'rtl' ); + getGBKit.mockReturnValue( { locale: 'en' } ); + + await configureLocale(); + + expect( document.body.classList.contains( 'rtl' ) ).toBe( false ); + } ); + + // `isRTL()` resolves to `_x( 'ltr', 'text direction' )`. The bundles we + // fetch from the `wp-plugins/gutenberg` GlotPress project don't carry that + // string — it belongs to core — so it must be injected for the Gutenberg + // components that branch on direction at runtime. + it( 'injects the text direction string that backs isRTL()', async () => { + getGBKit.mockReturnValue( { locale: 'he' } ); + + await configureLocale(); + + expect( setLocaleData ).toHaveBeenCalledWith( { + [ TEXT_DIRECTION_KEY ]: [ 'rtl' ], + } ); + } ); + + it( 'injects ltr for left-to-right locales', async () => { + getGBKit.mockReturnValue( { locale: 'de' } ); + + await configureLocale(); + + expect( setLocaleData ).toHaveBeenCalledWith( { + [ TEXT_DIRECTION_KEY ]: [ 'ltr' ], + } ); + } ); +} ); From d1accfb33a2d691220cacabf4417847806089370 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 11:06:04 -0400 Subject: [PATCH 02/21] feat: load stylesheets matching the editor text direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/visual-editor/index.jsx | 36 +++++++- src/utils/editor-environment.js | 7 +- src/utils/editor-environment.test.js | 9 ++ src/utils/editor-styles.js | 68 +++++++++++++-- src/utils/editor-styles.test.js | 110 +++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 src/utils/editor-styles.test.js diff --git a/src/components/visual-editor/index.jsx b/src/components/visual-editor/index.jsx index 36a5ecc31..0617492d6 100644 --- a/src/components/visual-editor/index.jsx +++ b/src/components/visual-editor/index.jsx @@ -21,6 +21,11 @@ import componentStyles from '@wordpress/components/build-style/style.css?inline' import blockEditorContentStyles from '@wordpress/block-editor/build-style/content.css?inline'; import blocksStyles from '@wordpress/block-library/build-style/style.css?inline'; import blocksEditorStyles from '@wordpress/block-library/build-style/editor.css?inline'; +// Right-to-left counterparts, generated upstream by `rtlcss`. +import componentStylesRTL from '@wordpress/components/build-style/style-rtl.css?inline'; +import blockEditorContentStylesRTL from '@wordpress/block-editor/build-style/content-rtl.css?inline'; +import blocksStylesRTL from '@wordpress/block-library/build-style/style-rtl.css?inline'; +import blocksEditorStylesRTL from '@wordpress/block-library/build-style/editor-rtl.css?inline'; /** * Internal dependencies @@ -40,6 +45,25 @@ const { useLayoutStyles, } = unlock( blockEditorPrivateApis ); +// Canvas styles, in cascade order, per text direction. Only one set is passed +// to the iframe: the `-rtl` bundles are full rewrites rather than overrides, +// so including both would let source order decide the winner. The iframe +// itself inherits `dir` from the parent document, which `configureLocale` +// sets. +const LTR_CANVAS_STYLES = [ + componentStyles, + blockEditorContentStyles, + blocksStyles, + blocksEditorStyles, +]; + +const RTL_CANVAS_STYLES = [ + componentStylesRTL, + blockEditorContentStylesRTL, + blocksStylesRTL, + blocksEditorStylesRTL, +]; + // Add some styles for alignwide/alignfull Post Content and its children. const alignCSS = `.is-root-container.alignwide { max-width: var(--wp--style--global--wide-size); margin-left: auto; margin-right: auto;} .is-root-container.alignwide:where(.is-layout-flow) > :not(.alignleft):not(.alignright) { max-width: var(--wp--style--global--wide-size);} @@ -83,6 +107,13 @@ const VisualEditor = forwardRef( function VisualEditor( { hideTitle }, ref ) { }; }, [] ); + // `configureLocale` resolves the direction onto the document before the + // editor renders, and it does not change for the editor's lifetime. + const canvasStyles = + document.documentElement.dir === 'rtl' + ? RTL_CANVAS_STYLES + : LTR_CANVAS_STYLES; + const styles = useEditorStyles( // `commonStyles` represent manually added notable styles that are missing. // The styles likely absent due to them being injected by the WP Admin @@ -90,10 +121,7 @@ const VisualEditor = forwardRef( function VisualEditor( { hideTitle }, ref ) { commonStyles, // Add sensible default styles if theme styles are not present. hasThemeStyles ? '' : defaultThemeStyles, - componentStyles, - blockEditorContentStyles, - blocksStyles, - blocksEditorStyles + ...canvasStyles ); const editorClasses = clsx( 'gutenberg-kit-visual-editor', { diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 339f8b6a7..9d430caf8 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -7,7 +7,7 @@ import { getGBKit, logException, } from './bridge'; -import { configureLocale } from './localization'; +import { configureLocale, isRTLLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; import { configureAjax } from './ajax'; import { initializeVideoPressAjaxBridge } from './videopress-bridge'; @@ -16,7 +16,7 @@ import EditorLoadError from '../components/editor-load-error'; import { setLogLevel, error } from './logger'; import { setUpGlobalErrorHandlers } from './global-error-handler'; import { Platform } from './platform'; -import './editor-styles'; +import { injectEditorStyles } from './editor-styles'; /** * Initialize the bundled editor by loading assets and configuring modules @@ -32,6 +32,9 @@ export async function setUpEditorEnvironment() { setLogLevelFromGBKit(); initializeFetchInterceptor(); await configureLocale(); + // Depends on the text direction `configureLocale` resolves, and must + // precede the editor render below. + injectEditorStyles( isRTLLocale( getGBKit().locale ) ); await initializeWordPressGlobals(); await configureApiFetch(); const pluginLoadResult = await loadPluginsIfEnabled(); diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index ea6c77008..ccbce95a4 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -23,6 +23,7 @@ import { configureLocale } from './localization.js'; import { configureApiFetch } from './api-fetch.js'; import { initializeEditor } from './editor.jsx'; import { initializeFetchInterceptor } from './fetch-interceptor.js'; +import { injectEditorStyles } from './editor-styles.js'; vi.mock( './bridge.js' ); vi.mock( './fetch-interceptor.js' ); @@ -45,6 +46,7 @@ vi.mock( './editor-loader.js', () => ( { vi.mock( './localization.js', () => ( { configureLocale: vi.fn(), + isRTLLocale: vi.fn( () => false ), } ) ); vi.mock( './api-fetch.js', () => ( { @@ -91,6 +93,10 @@ describe( 'setUpEditorEnvironment', () => { return Promise.resolve(); } ); + injectEditorStyles.mockImplementation( () => { + callOrder.push( 'injectEditorStyles' ); + } ); + initializeWordPressGlobals.mockImplementation( () => { callOrder.push( 'loadRemainingGlobals' ); } ); @@ -117,6 +123,9 @@ describe( 'setUpEditorEnvironment', () => { 'awaitGBKitGlobal', 'initializeFetchInterceptor', 'configureLocale', + // Styles depend on the direction `configureLocale` resolves, and + // must be injected before the editor renders. + 'injectEditorStyles', 'loadRemainingGlobals', 'configureApiFetch', 'configureAjax', diff --git a/src/utils/editor-styles.js b/src/utils/editor-styles.js index 47bba246a..54f62095d 100644 --- a/src/utils/editor-styles.js +++ b/src/utils/editor-styles.js @@ -2,8 +2,66 @@ * WordPress dependencies */ // Default styles that are needed for the editor. -import '@wordpress/components/build-style/style.css'; -import '@wordpress/block-editor/build-style/style.css'; -import '@wordpress/block-library/build-style/editor.css'; -import '@wordpress/format-library/build-style/style.css'; -import '@wordpress/editor/build-style/style.css'; +import componentsStyles from '@wordpress/components/build-style/style.css?inline'; +import blockEditorStyles from '@wordpress/block-editor/build-style/style.css?inline'; +import blockLibraryEditorStyles from '@wordpress/block-library/build-style/editor.css?inline'; +import formatLibraryStyles from '@wordpress/format-library/build-style/style.css?inline'; +import editorStyles from '@wordpress/editor/build-style/style.css?inline'; + +// Right-to-left counterparts, generated upstream by `rtlcss`. +import componentsStylesRTL from '@wordpress/components/build-style/style-rtl.css?inline'; +import blockEditorStylesRTL from '@wordpress/block-editor/build-style/style-rtl.css?inline'; +import blockLibraryEditorStylesRTL from '@wordpress/block-library/build-style/editor-rtl.css?inline'; +import formatLibraryStylesRTL from '@wordpress/format-library/build-style/style-rtl.css?inline'; +import editorStylesRTL from '@wordpress/editor/build-style/style-rtl.css?inline'; + +// Order is significant — it mirrors the cascade the stylesheets relied upon +// when they were imported for their side effects. +const LTR_STYLES = [ + componentsStyles, + blockEditorStyles, + blockLibraryEditorStyles, + formatLibraryStyles, + editorStyles, +]; + +const RTL_STYLES = [ + componentsStylesRTL, + blockEditorStylesRTL, + blockLibraryEditorStylesRTL, + formatLibraryStylesRTL, + editorStylesRTL, +]; + +const STYLE_ELEMENT_ID = 'gutenberg-kit-editor-styles'; + +/** + * Injects the editor stylesheets matching the document's text direction. + * + * Only one variant is ever inserted. The `-rtl` bundles are full rewrites of + * their left-to-right counterparts rather than overrides — across the five + * stylesheets roughly 690 selectors appear in both files with conflicting + * declarations, and almost none are scoped by a `[dir=rtl]` guard. Loading + * both would leave the cascade to resolve those conflicts by source order, + * applying one direction to every user regardless of locale. + * + * WordPress solves this server-side by swapping the enqueued file + * (`is_rtl() ? 'style-rtl.css' : 'style.css'`). GutenbergKit ships both + * variants in the bundle and selects between them here instead, since the + * editor loads a single static `index.html`. + * + * @param {boolean} isRTL Whether the editor renders right-to-left. + * + * @return {void} + */ +export function injectEditorStyles( isRTL ) { + const existing = document.getElementById( STYLE_ELEMENT_ID ); + if ( existing ) { + existing.remove(); + } + + const element = document.createElement( 'style' ); + element.id = STYLE_ELEMENT_ID; + element.textContent = ( isRTL ? RTL_STYLES : LTR_STYLES ).join( '\n' ); + document.head.appendChild( element ); +} diff --git a/src/utils/editor-styles.test.js b/src/utils/editor-styles.test.js new file mode 100644 index 000000000..a06066543 --- /dev/null +++ b/src/utils/editor-styles.test.js @@ -0,0 +1,110 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { injectEditorStyles } from './editor-styles'; + +// Vitest runs with `css: false`, so `?inline` imports resolve to empty strings +// and the real stylesheets never reach the module. Stub each one with an +// identifiable marker so the direction selection is observable. +vi.mock( '@wordpress/components/build-style/style.css?inline', () => ( { + default: '.ltr-components{}', +} ) ); +vi.mock( '@wordpress/block-editor/build-style/style.css?inline', () => ( { + default: '.ltr-block-editor{}', +} ) ); +vi.mock( '@wordpress/block-library/build-style/editor.css?inline', () => ( { + default: '.ltr-block-library{}', +} ) ); +vi.mock( '@wordpress/format-library/build-style/style.css?inline', () => ( { + default: '.ltr-format-library{}', +} ) ); +vi.mock( '@wordpress/editor/build-style/style.css?inline', () => ( { + default: '.ltr-editor{}', +} ) ); + +vi.mock( '@wordpress/components/build-style/style-rtl.css?inline', () => ( { + default: '.rtl-components{}', +} ) ); +vi.mock( '@wordpress/block-editor/build-style/style-rtl.css?inline', () => ( { + default: '.rtl-block-editor{}', +} ) ); +vi.mock( '@wordpress/block-library/build-style/editor-rtl.css?inline', () => ( { + default: '.rtl-block-library{}', +} ) ); +vi.mock( '@wordpress/format-library/build-style/style-rtl.css?inline', () => ( { + default: '.rtl-format-library{}', +} ) ); +vi.mock( '@wordpress/editor/build-style/style-rtl.css?inline', () => ( { + default: '.rtl-editor{}', +} ) ); + +const STYLE_ELEMENT_ID = 'gutenberg-kit-editor-styles'; + +const getStyleElement = () => document.getElementById( STYLE_ELEMENT_ID ); + +describe( 'injectEditorStyles', () => { + beforeEach( () => { + getStyleElement()?.remove(); + } ); + + it( 'injects a single style element into the document head', () => { + injectEditorStyles( false ); + + const element = getStyleElement(); + expect( element ).not.toBeNull(); + expect( element.tagName ).toBe( 'STYLE' ); + expect( element.parentElement ).toBe( document.head ); + } ); + + it( 'injects the left-to-right stylesheets in cascade order', () => { + injectEditorStyles( false ); + + expect( getStyleElement().textContent ).toBe( + [ + '.ltr-components{}', + '.ltr-block-editor{}', + '.ltr-block-library{}', + '.ltr-format-library{}', + '.ltr-editor{}', + ].join( '\n' ) + ); + } ); + + it( 'injects the right-to-left stylesheets in cascade order', () => { + injectEditorStyles( true ); + + expect( getStyleElement().textContent ).toBe( + [ + '.rtl-components{}', + '.rtl-block-editor{}', + '.rtl-block-library{}', + '.rtl-format-library{}', + '.rtl-editor{}', + ].join( '\n' ) + ); + } ); + + // The `-rtl` bundles are full rewrites rather than overrides, so injecting + // both would let source order decide which direction every user gets. + it( 'injects only one direction at a time', () => { + injectEditorStyles( true ); + + const content = getStyleElement().textContent; + expect( content ).toContain( '.rtl-components{}' ); + expect( content ).not.toContain( '.ltr-components{}' ); + } ); + + it( 'replaces the previous styles rather than accumulating them', () => { + injectEditorStyles( false ); + injectEditorStyles( true ); + + expect( + document.querySelectorAll( `#${ STYLE_ELEMENT_ID }` ) + ).toHaveLength( 1 ); + } ); +} ); From 7f5a1ec714047c411a957747d229c960021bf130 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 11:39:24 -0400 Subject: [PATCH 03/21] feat(demo-ios): forward Xcode's App Language to the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Sources/Views/DemoAppLocale.swift | 125 ++++++++++++++++++ .../Sources/Views/SitePreparationView.swift | 27 ++++ 2 files changed, 152 insertions(+) create mode 100644 ios/Demo-iOS/Sources/Views/DemoAppLocale.swift diff --git a/ios/Demo-iOS/Sources/Views/DemoAppLocale.swift b/ios/Demo-iOS/Sources/Views/DemoAppLocale.swift new file mode 100644 index 000000000..94e6dd57e --- /dev/null +++ b/ios/Demo-iOS/Sources/Views/DemoAppLocale.swift @@ -0,0 +1,125 @@ +import Foundation + +/// Resolves the language the app was launched in to a locale the editor ships +/// translations for. +/// +/// Xcode's *App Language* scheme option launches the app with +/// `-AppleLanguages ()`, which surfaces in `Locale.preferredLanguages`. +/// Forwarding that to `EditorConfiguration` lets the demo app exercise the +/// editor's localization by changing a dropdown rather than editing code. +/// +/// - Note: `Bundle.main.preferredLocalizations` is deliberately *not* used. It +/// filters against the localizations the app bundle itself ships, and the +/// demo app ships only English, so every selection would collapse to `en`. +/// +/// - Note: Xcode's *Right-to-Left Pseudolanguage* options are not supported. +/// They are not languages: Xcode launches the app with `-AppleTextDirection +/// YES -NSForceRightToLeftWritingDirection YES` and no `-AppleLanguages`, so +/// `Locale.preferredLanguages` still reports the device language and the +/// editor loads the corresponding translations. UIKit mirrors its own layout +/// from those flags, so the app around the editor will flip while the editor +/// itself does not. To exercise right-to-left rendering in the editor, select +/// a real right-to-left language such as Arabic or Hebrew. +/// +/// - Important: This duplicates the resolution chain that +/// [PR #492](https://github.com/wordpress-mobile/GutenbergKit/pull/492) adds +/// to the library as `LocaleResolver`, matching the Android implementation +/// already merged in #493. It exists only because the iOS half is frozen. +/// When that lands, delete this type and pass `Locale.current` to +/// `setLocale(_:)` directly — the library will do the resolving. +/// +/// - Note: This is not a view. It lives under `Views/` because that is the +/// only `PBXFileSystemSynchronizedRootGroup` in the demo app's project, so +/// files added there are compiled without hand-editing `project.pbxproj`. +enum DemoAppLocale { + + /// The editor locale matching the language the app is running in. + static var current: String { + resolve(preferredLanguages: Locale.preferredLanguages) + } + + /// Resolves the first supported locale among `preferredLanguages`. + /// + /// Falls back to English when nothing matches, mirroring the editor's own + /// behavior for unshipped locales. + static func resolve( + preferredLanguages: [String], + supportedLocales: Set = Self.supportedLocales + ) -> String { + for language in preferredLanguages { + if let match = resolve(language: language, supportedLocales: supportedLocales) { + return match + } + } + return defaultLocale + } + + /// Resolution chain for a single tag, mirroring the Android `LocaleResolver`: + /// `language-region`, then a script-implied region, then the bare language. + /// + /// Tags carrying a private-use region need no special handling: `XA`/`XB` + /// match no bundle, so the chain falls through to the base language. + private static func resolve(language: String, supportedLocales: Set) -> String? { + let normalized = language.replacingOccurrences(of: "_", with: "-") + let components = Locale.Components(identifier: normalized) + + guard let code = components.languageComponents.languageCode?.identifier.lowercased(), + !code.isEmpty + else { + return nil + } + + // Android's `Locale` still emits legacy ISO 639-1 codes for these + // languages. Aliased here too so both platforms resolve alike. + let language = languageAliases[code] ?? code + + if let region = components.languageComponents.region?.identifier.lowercased() { + let tag = "\(language)-\(region)" + if supportedLocales.contains(tag) { + return tag + } + } + + // For macrolanguages shipped only as regional bundles (`zh-cn`, + // `zh-tw`), a script subtag indicates which one is intended. + if let script = components.languageComponents.script?.identifier.lowercased(), + let implied = scriptImpliedTag(language: language, script: script), + supportedLocales.contains(implied) { + return implied + } + + return supportedLocales.contains(language) ? language : nil + } + + private static func scriptImpliedTag(language: String, script: String) -> String? { + switch (language, script) { + case ("zh", "hans"): return "zh-cn" + case ("zh", "hant"): return "zh-tw" + default: return nil + } + } + + private static let languageAliases = [ + "iw": "he", + "in": "id", + "no": "nb", + ] + + static let defaultLocale = "en" + + /// The locales the editor ships translations for. + /// + /// Mirrors `supported-locales.json`, which the JS build emits from + /// `src/translations/`. Hardcoded rather than read from the resource bundle + /// because this whole type is temporary scaffolding — see the type-level + /// note. Duplicating the list here keeps the eventual deletion to a single + /// file, with no library API added and then removed. + static let supportedLocales: Set = [ + "ar", "bg", "bo", "ca", "cs", "cy", "da", "de", "el", + "en-au", "en-ca", "en-gb", "en-nz", "en-za", + "es", "es-ar", "es-cl", "es-cr", "fa", "fr", "gl", "he", "hr", "hu", + "id", "is", "it", "ja", "ka", "ko", "nb", "nl", "nl-be", "pl", + "pt", "pt-br", "ro", "ru", "sk", "sq", "sr", "sv", "th", "tr", + "uk", "ur", "vi", "zh-cn", "zh-tw", + ] +} diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index 68d6e0b8a..f175d7e97 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -97,6 +97,7 @@ struct SitePreparationView: View { KeyValueRow(key: "API Root", value: editorConfiguration.siteApiRoot.absoluteString) KeyValueRow(key: "Supports Block Assets", value: editorConfiguration.shouldUsePlugins) KeyValueRow(key: "Supports Theme Styles", value: editorConfiguration.shouldUseThemeStyles) + KeyValueRow(key: "Editor Locale", value: localeSummary(for: editorConfiguration)) } } @@ -110,6 +111,28 @@ struct SitePreparationView: View { } } + /// Describes the locale the editor will use, and the language it was + /// resolved from when the two differ. + /// + /// Makes the Xcode *App Language* selection self-verifying: without it, a + /// language with no shipped bundle silently renders in English and looks + /// identical to the selection being ignored entirely. + private func localeSummary(for configuration: EditorConfiguration) -> String { + let resolved = configuration.locale + guard let requested = Locale.preferredLanguages.first else { + return resolved + } + + if requested.replacingOccurrences(of: "_", with: "-").lowercased() == resolved { + return resolved + } + + let outcome = resolved == DemoAppLocale.defaultLocale + ? "no bundle, using default" + : "resolved" + return "\(resolved) — \(outcome) from \(requested)" + } + var preloadSection: some View { Section { Button("Prepare Editor") { @@ -283,6 +306,10 @@ class SitePreparationViewModel { private static func applyDemoAppDefaults(to configuration: EditorConfiguration) -> EditorConfiguration { configuration.toBuilder() .setNativeInserterEnabled(true) + // Forwards Xcode's *App Language* selection to the editor so + // localization — including right-to-left — can be exercised + // without code changes. + .setLocale(DemoAppLocale.current) .build() } From d055062611e896e2ef15a84ab7906e152a689b02 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 12:24:30 -0400 Subject: [PATCH 04/21] fix: Apply border styles for RTL layouts Absolutely targeting the right border resulted in unexpected styling for RTL language layout. --- src/components/editor-toolbar/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/editor-toolbar/style.scss b/src/components/editor-toolbar/style.scss index 274a25841..60b9e0aaf 100644 --- a/src/components/editor-toolbar/style.scss +++ b/src/components/editor-toolbar/style.scss @@ -93,7 +93,7 @@ $scroll-indicator-elevation: 32; } .gutenberg-kit-editor-toolbar .components-toolbar-group { - border-right-color: $border-color; + border-inline-end-color: $border-color; min-height: $min-touch-target-size; // Reset Gutenberg's negative margin that oddly create a gap at the top/bottom // of the toolbar, rather than extending the button height as intended in From e8a5ce19669616e81d8c5eb2806570ebe6f2d821 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 12:51:34 -0400 Subject: [PATCH 05/21] feat(demo-android): forward the per-app language to the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- android/app/src/main/AndroidManifest.xml | 1 + .../com/example/gutenbergkit/DemoAppLocale.kt | 50 ++++++++++++++++ .../gutenbergkit/SitePreparationActivity.kt | 60 ++++++++++++++++++- .../gutenbergkit/SitePreparationViewModel.kt | 6 ++ .../app/src/main/res/xml/locales_config.xml | 33 ++++++++++ 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/example/gutenbergkit/DemoAppLocale.kt create mode 100644 android/app/src/main/res/xml/locales_config.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d5a954927..07a86baac 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.GutenbergKit" diff --git a/android/app/src/main/java/com/example/gutenbergkit/DemoAppLocale.kt b/android/app/src/main/java/com/example/gutenbergkit/DemoAppLocale.kt new file mode 100644 index 000000000..cd08febb0 --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/DemoAppLocale.kt @@ -0,0 +1,50 @@ +package com.example.gutenbergkit + +import android.app.LocaleManager +import android.content.Context +import android.os.Build +import java.util.Locale + +/** + * Reads the language the demo app is running in, so the editor can be told + * which translations to load. + * + * The language is chosen through the system's per-app language picker + * (Settings > Apps > GutenbergKit > Language), which offers the locales + * declared in `res/xml/locales_config.xml`. Forwarding it to + * `EditorConfiguration` lets the editor's localization — including + * right-to-left rendering — be exercised without code changes. + * + * Unlike the iOS demo app, no resolution logic lives here: + * `EditorConfiguration.Builder.setLocale(Locale)` already resolves against the + * bundled translations via the library's `LocaleResolver`. + */ +object DemoAppLocale { + + /** + * The locale to hand the editor. + * + * Reads the platform's [LocaleManager] directly rather than going through + * `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. + * + * Falls back to the device language when no per-app language is set, or on + * Android versions predating per-app languages (API < 33). + */ + fun current(context: Context): Locale { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return Locale.getDefault() + } + + val locales = context.getSystemService(LocaleManager::class.java) + ?.applicationLocales + + if (locales == null || locales.isEmpty) { + return Locale.getDefault() + } + return locales[0] + } +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index fa5197eae..5e274e4dc 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -2,11 +2,15 @@ package com.example.gutenbergkit import android.content.Context import android.content.Intent +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings import org.json.JSONObject import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -49,6 +53,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp @@ -202,8 +207,13 @@ fun SitePreparationScreen( onBrowsePosts: (EditorConfiguration, EditorDependencies?, PostTypeDetails) -> Unit ) { val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current - LaunchedEffect(Unit) { + // Keyed on the locale so changing the per-app language rebuilds the + // configuration. The system recreates the activity on a locale change, but + // the view model survives it, so keying on `Unit` would keep serving the + // configuration built with the previous locale. + LaunchedEffect(DemoAppLocale.current(context)) { viewModel.startLoading() } @@ -515,10 +525,58 @@ private fun EditorConfigurationDetailsCard(configuration: EditorConfiguration) { KeyValueRow(key = "API Root", value = configuration.siteApiRoot) KeyValueBooleanRow(key = "Supports Block Assets", value = configuration.plugins) KeyValueBooleanRow(key = "Supports Theme Styles", value = configuration.themeStyles) + EditorLocaleRow(locale = configuration.locale) } } } +/** + * Shows the locale the editor will use, linking to the system's per-app + * language picker where one exists. + * + * The value is what the library resolved the app's language to, not the + * language itself — a locale with no bundled translations resolves to `en`, + * which is otherwise indistinguishable from the selection being ignored. + * + * The link is omitted below Android 13 (API 33), which has no per-app language + * screen to open. `AppCompatDelegate` still honors a per-app locale there, but + * only the app itself can set it. + */ +@Composable +private fun EditorLocaleRow(locale: String?) { + val context = LocalContext.current + val resolved = locale ?: "en" + val canOpenSettings = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + if (!canOpenSettings) { + KeyValueRow(key = "Editor Locale", value = resolved) + return + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + context.startActivity( + Intent( + Settings.ACTION_APP_LOCALE_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ) + ) + } + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + KeyValueRow(key = "Editor Locale", value = resolved) + Text( + text = "Change", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } +} + @Composable private fun LocalCachesCard( isLoading: Boolean, diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index 360b08af0..38b22de39 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -224,6 +224,9 @@ class SitePreparationViewModel( .setAuthHeader("") .setCookies(emptyMap()) .setEnableOfflineMode(true) + // Forwards the per-app language to the editor so localization can + // be exercised from the system language picker. + .setLocale(DemoAppLocale.current(getApplication())) .build() } @@ -278,6 +281,9 @@ class SitePreparationViewModel( .setCookies(emptyMap()) .setEnableNetworkLogging(true) .setEnableAssetCaching(capabilities.supportsPlugins) + // Forwards the per-app language to the editor so localization can + // be exercised from the system language picker. + .setLocale(DemoAppLocale.current(getApplication())) .build() } diff --git a/android/app/src/main/res/xml/locales_config.xml b/android/app/src/main/res/xml/locales_config.xml new file mode 100644 index 000000000..cf0e0969a --- /dev/null +++ b/android/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + From 631ced0e5c832243e3912c202b624c735449ed9f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 13:18:47 -0400 Subject: [PATCH 06/21] fix: Update asymmetric properties for RTL language layouts Avoid absolute direction styles that break RTL language layouts. --- src/components/editor-toolbar/style.scss | 2 +- src/index.scss | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/editor-toolbar/style.scss b/src/components/editor-toolbar/style.scss index 60b9e0aaf..65851d24a 100644 --- a/src/components/editor-toolbar/style.scss +++ b/src/components/editor-toolbar/style.scss @@ -147,7 +147,7 @@ $scroll-indicator-elevation: 32; // Style the add block button with rounded black background .gutenberg-kit-editor-toolbar .gutenberg-kit-add-block-button { - margin-left: 8px; + margin-inline-start: 8px; svg { background: #eae9ec; diff --git a/src/index.scss b/src/index.scss index 9836f9a90..49ab253f3 100644 --- a/src/index.scss +++ b/src/index.scss @@ -30,7 +30,7 @@ $baseline-interactive-font-size: 17px; /* Popover */ .components-popover__header-title { - padding-left: 20px; + padding-inline-start: 20px; } .components-popover.is-expanded .components-popover__content { @@ -129,7 +129,7 @@ $baseline-interactive-font-size: 17px; .block-editor-inserter__panel-title { font-size: 15px; font-weight: 600; - margin-left: 12px; + margin-inline-start: 12px; } .components-draggable-drag-component-root { From 1b04755e3322f782171ad69629b3acecb69300a3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 13:19:21 -0400 Subject: [PATCH 07/21] fix: Remove unused styles The relevant UI element no longer exists. --- src/index.scss | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/index.scss b/src/index.scss index 49ab253f3..21715a83c 100644 --- a/src/index.scss +++ b/src/index.scss @@ -67,12 +67,6 @@ $baseline-interactive-font-size: 17px; min-height: 100vh; } - .block-inspector-siderbar { - background: #f6f6fbff; - border-left: 0.5px solid #c8c7cc; - width: 320px; - } - /* Inserter (Mobile Design) */ // Inserter tab buttons From 6e5ab950b0eda0cb73977fa5f1deb5642b57613d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 13:39:33 -0400 Subject: [PATCH 08/21] fix(demo-android): refresh the locale when returning from the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../gutenbergkit/SitePreparationActivity.kt | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index 5e274e4dc..c7eb7c629 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -47,9 +47,13 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -57,8 +61,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.LocalLifecycleOwner import com.example.gutenbergkit.ui.theme.AppTheme +import java.util.Locale import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer @@ -209,11 +217,18 @@ fun SitePreparationScreen( val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + // Re-read on resume so returning from the system language picker is + // noticed. Changing the per-app language does not always recreate this + // activity — when the picker is another app's task, this one is merely + // stopped and resumed — so without this the composition never re-reads the + // locale and the screen keeps showing the previous one. + val locale = rememberLocaleOnResume() + // Keyed on the locale so changing the per-app language rebuilds the - // configuration. The system recreates the activity on a locale change, but - // the view model survives it, so keying on `Unit` would keep serving the + // configuration. Where the system does recreate the activity, the view + // model survives it, so keying on `Unit` would keep serving the // configuration built with the previous locale. - LaunchedEffect(DemoAppLocale.current(context)) { + LaunchedEffect(locale) { viewModel.startLoading() } @@ -530,6 +545,33 @@ private fun EditorConfigurationDetailsCard(configuration: EditorConfiguration) { } } +/** + * The app's current locale, re-read every time the activity resumes. + * + * Returning from the system language picker does not reliably recreate this + * activity — the picker belongs to another task, so this one is often just + * stopped and resumed — and a plain read during composition would never see + * the new value. Observing `ON_RESUME` covers both cases. + */ +@Composable +private fun rememberLocaleOnResume(): Locale { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var locale by remember { mutableStateOf(DemoAppLocale.current(context)) } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + locale = DemoAppLocale.current(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + return locale +} + /** * Shows the locale the editor will use, linking to the system's per-app * language picker where one exists. From a49dfa57dfe186cf4a6dbb784bbab7dd4fe1de51 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 15:38:34 -0400 Subject: [PATCH 09/21] docs: Reduce comment verbosity Remove comments deemed unnecessary. --- .../example/gutenbergkit/SitePreparationActivity.kt | 13 +------------ .../gutenbergkit/SitePreparationViewModel.kt | 4 ---- .../Sources/Views/SitePreparationView.swift | 3 --- src/components/visual-editor/index.jsx | 5 ----- src/utils/editor-environment.js | 2 -- src/utils/editor-environment.test.js | 2 -- src/utils/editor-styles.js | 2 -- 7 files changed, 1 insertion(+), 30 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index c7eb7c629..f11343e3f 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -218,16 +218,9 @@ fun SitePreparationScreen( val context = LocalContext.current // Re-read on resume so returning from the system language picker is - // noticed. Changing the per-app language does not always recreate this - // activity — when the picker is another app's task, this one is merely - // stopped and resumed — so without this the composition never re-reads the - // locale and the screen keeps showing the previous one. + // noticed. val locale = rememberLocaleOnResume() - // Keyed on the locale so changing the per-app language rebuilds the - // configuration. Where the system does recreate the activity, the view - // model survives it, so keying on `Unit` would keep serving the - // configuration built with the previous locale. LaunchedEffect(locale) { viewModel.startLoading() } @@ -579,10 +572,6 @@ private fun rememberLocaleOnResume(): Locale { * The value is what the library resolved the app's language to, not the * language itself — a locale with no bundled translations resolves to `en`, * which is otherwise indistinguishable from the selection being ignored. - * - * The link is omitted below Android 13 (API 33), which has no per-app language - * screen to open. `AppCompatDelegate` still honors a per-app locale there, but - * only the app itself can set it. */ @Composable private fun EditorLocaleRow(locale: String?) { diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index 38b22de39..cc43e89d5 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -224,8 +224,6 @@ class SitePreparationViewModel( .setAuthHeader("") .setCookies(emptyMap()) .setEnableOfflineMode(true) - // Forwards the per-app language to the editor so localization can - // be exercised from the system language picker. .setLocale(DemoAppLocale.current(getApplication())) .build() } @@ -281,8 +279,6 @@ class SitePreparationViewModel( .setCookies(emptyMap()) .setEnableNetworkLogging(true) .setEnableAssetCaching(capabilities.supportsPlugins) - // Forwards the per-app language to the editor so localization can - // be exercised from the system language picker. .setLocale(DemoAppLocale.current(getApplication())) .build() } diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index f175d7e97..f1fbb81a3 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -306,9 +306,6 @@ class SitePreparationViewModel { private static func applyDemoAppDefaults(to configuration: EditorConfiguration) -> EditorConfiguration { configuration.toBuilder() .setNativeInserterEnabled(true) - // Forwards Xcode's *App Language* selection to the editor so - // localization — including right-to-left — can be exercised - // without code changes. .setLocale(DemoAppLocale.current) .build() } diff --git a/src/components/visual-editor/index.jsx b/src/components/visual-editor/index.jsx index 0617492d6..9b62da745 100644 --- a/src/components/visual-editor/index.jsx +++ b/src/components/visual-editor/index.jsx @@ -45,11 +45,6 @@ const { useLayoutStyles, } = unlock( blockEditorPrivateApis ); -// Canvas styles, in cascade order, per text direction. Only one set is passed -// to the iframe: the `-rtl` bundles are full rewrites rather than overrides, -// so including both would let source order decide the winner. The iframe -// itself inherits `dir` from the parent document, which `configureLocale` -// sets. const LTR_CANVAS_STYLES = [ componentStyles, blockEditorContentStyles, diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 9d430caf8..8dd0b7023 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -32,8 +32,6 @@ export async function setUpEditorEnvironment() { setLogLevelFromGBKit(); initializeFetchInterceptor(); await configureLocale(); - // Depends on the text direction `configureLocale` resolves, and must - // precede the editor render below. injectEditorStyles( isRTLLocale( getGBKit().locale ) ); await initializeWordPressGlobals(); await configureApiFetch(); diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index ccbce95a4..251a38f63 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -123,8 +123,6 @@ describe( 'setUpEditorEnvironment', () => { 'awaitGBKitGlobal', 'initializeFetchInterceptor', 'configureLocale', - // Styles depend on the direction `configureLocale` resolves, and - // must be injected before the editor renders. 'injectEditorStyles', 'loadRemainingGlobals', 'configureApiFetch', diff --git a/src/utils/editor-styles.js b/src/utils/editor-styles.js index 54f62095d..3aa686ea4 100644 --- a/src/utils/editor-styles.js +++ b/src/utils/editor-styles.js @@ -15,8 +15,6 @@ import blockLibraryEditorStylesRTL from '@wordpress/block-library/build-style/ed import formatLibraryStylesRTL from '@wordpress/format-library/build-style/style-rtl.css?inline'; import editorStylesRTL from '@wordpress/editor/build-style/style-rtl.css?inline'; -// Order is significant — it mirrors the cascade the stylesheets relied upon -// when they were imported for their side effects. const LTR_STYLES = [ componentsStyles, blockEditorStyles, From c225998ea92a4ead996572dc6bcfda5b691486f6 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 15:48:39 -0400 Subject: [PATCH 10/21] refactor(demo-ios): move DemoAppLocale into Services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj | 4 ++++ ios/Demo-iOS/Sources/{Views => Services}/DemoAppLocale.swift | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) rename ios/Demo-iOS/Sources/{Views => Services}/DemoAppLocale.swift (95%) diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index 185d047c4..296a464c5 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 246852562EAABB7800ED1F09 /* WordPressAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 0C4F59A12BEFF4980028BD96 /* WordPressAPI */; }; 2468526B2EAACCA100ED1F09 /* AuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 246852682EAACCA100ED1F09 /* AuthenticationManager.swift */; }; 2468526C2EAACCA100ED1F09 /* ConfigurationStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 246852692EAACCA100ED1F09 /* ConfigurationStorage.swift */; }; + 2FCF7A593017EC80008F5560 /* DemoAppLocale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCF7A503017EC80008F5560 /* DemoAppLocale.swift */; }; BB0000012F11000000000001 /* GutenbergKitHTTP in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000012F11000000000002 /* GutenbergKitHTTP */; }; /* End PBXBuildFile section */ @@ -36,6 +37,7 @@ 0CE8E7892C339B0600B9DC67 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 246852682EAACCA100ED1F09 /* AuthenticationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationManager.swift; sourceTree = ""; }; 246852692EAACCA100ED1F09 /* ConfigurationStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationStorage.swift; sourceTree = ""; }; + 2FCF7A503017EC80008F5560 /* DemoAppLocale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoAppLocale.swift; sourceTree = ""; }; AA0000012F00000000000001 /* GutenbergUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GutenbergUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -124,6 +126,7 @@ isa = PBXGroup; children = ( 246852682EAACCA100ED1F09 /* AuthenticationManager.swift */, + 2FCF7A503017EC80008F5560 /* DemoAppLocale.swift */, 246852692EAACCA100ED1F09 /* ConfigurationStorage.swift */, ); path = Services; @@ -272,6 +275,7 @@ 0C4F59A62BEFF4980028BD96 /* ConfigurationItem.swift in Sources */, 0CE8E78E2C339B0600B9DC67 /* GutenbergApp.swift in Sources */, 2468526B2EAACCA100ED1F09 /* AuthenticationManager.swift in Sources */, + 2FCF7A593017EC80008F5560 /* DemoAppLocale.swift in Sources */, 2468526C2EAACCA100ED1F09 /* ConfigurationStorage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Demo-iOS/Sources/Views/DemoAppLocale.swift b/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift similarity index 95% rename from ios/Demo-iOS/Sources/Views/DemoAppLocale.swift rename to ios/Demo-iOS/Sources/Services/DemoAppLocale.swift index 94e6dd57e..a7c484427 100644 --- a/ios/Demo-iOS/Sources/Views/DemoAppLocale.swift +++ b/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift @@ -27,10 +27,6 @@ import Foundation /// already merged in #493. It exists only because the iOS half is frozen. /// When that lands, delete this type and pass `Locale.current` to /// `setLocale(_:)` directly — the library will do the resolving. -/// -/// - Note: This is not a view. It lives under `Views/` because that is the -/// only `PBXFileSystemSynchronizedRootGroup` in the demo app's project, so -/// files added there are compiled without hand-editing `project.pbxproj`. enum DemoAppLocale { /// The editor locale matching the language the app is running in. From 99b76efac3663bb47d59c513bb64c8c32a08d6b5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 19:37:57 -0400 Subject: [PATCH 11/21] fix(ios): declare the editor's language to assistive technology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Sources/EditorViewController.swift | 6 ++ .../BlockInserter/BlockInserterView.swift | 2 + .../Views/EditorAccessibilityLanguage.swift | 59 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 808c7034f..80c51c25d 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -484,8 +484,14 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro onClose: { [weak self] in self?.notifyInserterClosed() } ) .environmentObject(htmlPreviewManager) + .environment(\.locale, Locale(identifier: configuration.locale)) }) + // The web content declares its language via `documentElement.lang`. + // This is a separate accessibility tree showing the same UI in the same + // locale, so it has to declare the language itself. + host.view.accessibilityLanguage = configuration.locale + context.viewController = host // Set presentation delegate to track dismissal diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift index 07f39a1a3..6a16d80dc 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -72,6 +72,7 @@ struct BlockInserterView: View { insertCameraMedia(media) } .ignoresSafeArea() + .editorAccessibilityLanguage() } .animation(.smooth(duration: 2), value: viewModel.isProcessingMedia) .animation(.snappy, value: inlineSelectedMediaItems.count) @@ -91,6 +92,7 @@ struct BlockInserterView: View { } ) } + .editorAccessibilityLanguage() } .background( GeometryReader { geometry in diff --git a/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift b/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift new file mode 100644 index 000000000..31b1d2cfc --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift @@ -0,0 +1,59 @@ +import SwiftUI + +extension View { + /// Declares the editor's language for this view's accessibility tree, so + /// assistive technology selects a matching speech voice. + /// + /// Apply at every presentation boundary. `accessibilityLanguage` is + /// inherited down a view hierarchy, but a sheet is presented as a sibling + /// rather than a descendant, so its content does not inherit the value set + /// on the presenting view. The locale comes from the SwiftUI environment, + /// which *does* cross sheets. + /// + /// - Note: Content rendered out of process — the system photo picker — has + /// its own accessibility tree and cannot be annotated from here. + func editorAccessibilityLanguage() -> some View { + modifier(EditorAccessibilityLanguageModifier()) + } +} + +private struct EditorAccessibilityLanguageModifier: ViewModifier { + @Environment(\.locale) private var locale + + func body(content: Content) -> some View { + content.background( + AccessibilityLanguageHost(language: locale.identifier) + .accessibilityHidden(true) + ) + } +} + +/// Applies `accessibilityLanguage` to the UIKit view hosting this content. +/// +/// SwiftUI has no equivalent modifier — the property exists only on `UIView` +/// and `UIAccessibilityElement`. +private struct AccessibilityLanguageHost: UIViewRepresentable { + let language: String + + func makeUIView(context: Context) -> UIView { + UIView() + } + + func updateUIView(_ uiView: UIView, context: Context) { + uiView.rootHostingView?.accessibilityLanguage = language + } +} + +private extension UIView { + /// The outermost view of the hosting controller presenting this view. + var rootHostingView: UIView? { + var candidate: UIView? = self + while let view = candidate { + if let controller = view.next as? UIViewController { + return controller.view + } + candidate = view.superview + } + return nil + } +} From 57789598ed8c9560a74f3ff81d4ef49f4bb6bc94 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 19:46:37 -0400 Subject: [PATCH 12/21] fix(demo-ios): stop English falling through to the next preferred language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/Demo-iOS/Sources/Services/DemoAppLocale.swift | 15 +++++++++++++++ .../Sources/Views/SitePreparationView.swift | 10 +++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift b/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift index a7c484427..076c58b3a 100644 --- a/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift +++ b/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift @@ -46,10 +46,25 @@ enum DemoAppLocale { if let match = resolve(language: language, supportedLocales: supportedLocales) { return match } + + // English is the editor's source language, so no `en` bundle ships + // and the lookup above cannot match it. Stop rather than falling + // through to the next preferred language: the user asked for + // English, and English is what the editor renders without a bundle. + // Regional variants that do ship — `en-gb`, `en-au` — match above. + if isEnglish(language) { + return defaultLocale + } } return defaultLocale } + private static func isEnglish(_ language: String) -> Bool { + let normalized = language.replacingOccurrences(of: "_", with: "-") + return Locale.Components(identifier: normalized) + .languageComponents.languageCode?.identifier.lowercased() == defaultLocale + } + /// Resolution chain for a single tag, mirroring the Android `LocaleResolver`: /// `language-region`, then a script-implied region, then the bare language. /// diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index f1fbb81a3..4bd0c0846 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -123,10 +123,18 @@ struct SitePreparationView: View { return resolved } - if requested.replacingOccurrences(of: "_", with: "-").lowercased() == resolved { + let normalized = requested.replacingOccurrences(of: "_", with: "-").lowercased() + if normalized == resolved { return resolved } + // English ships no bundle of its own — it is the editor's source + // language — so describe it as the language being used rather than as + // a fallback from something else. + if resolved == DemoAppLocale.defaultLocale, normalized.hasPrefix(DemoAppLocale.defaultLocale) { + return "\(resolved) — \(requested)" + } + let outcome = resolved == DemoAppLocale.defaultLocale ? "no bundle, using default" : "resolved" From f96418e673385b36b0beaffc76a88f592d67c61e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 27 Jul 2026 20:41:48 -0400 Subject: [PATCH 13/21] 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) --- .../Sources/Views/EditorAccessibilityLanguage.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift b/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift index 31b1d2cfc..81fed554f 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift @@ -1,3 +1,5 @@ +#if canImport(UIKit) +import UIKit import SwiftUI extension View { @@ -57,3 +59,4 @@ private extension UIView { return nil } } +#endif From 4fd93be5710d763a9340cd7f86c1b4d0e9902bf2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 08:42:13 -0400 Subject: [PATCH 14/21] 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) --- src/utils/editor-styles.js | 15 ++++++++++++++- src/utils/editor-styles.test.js | 21 ++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/utils/editor-styles.js b/src/utils/editor-styles.js index 3aa686ea4..44f2258cd 100644 --- a/src/utils/editor-styles.js +++ b/src/utils/editor-styles.js @@ -48,6 +48,15 @@ const STYLE_ELEMENT_ID = 'gutenberg-kit-editor-styles'; * variants in the bundle and selects between them here instead, since the * editor loads a single static `index.html`. * + * The element is inserted before the first stylesheet link rather than + * appended. These stylesheets are the base layer that GutenbergKit's own + * styles build on, and several selectors tie on specificity across the two + * (`.gutenberg-kit .components-button` against + * `.editor-visual-editor .components-button`, both `0,2,0`). Ties resolve by + * source order, so appending would silently hand those to WordPress. Building + * these as a side-effect import placed them first; inserting first preserves + * that. + * * @param {boolean} isRTL Whether the editor renders right-to-left. * * @return {void} @@ -61,5 +70,9 @@ export function injectEditorStyles( isRTL ) { const element = document.createElement( 'style' ); element.id = STYLE_ELEMENT_ID; element.textContent = ( isRTL ? RTL_STYLES : LTR_STYLES ).join( '\n' ); - document.head.appendChild( element ); + + const firstStylesheet = document.head.querySelector( + 'link[rel="stylesheet"], style' + ); + document.head.insertBefore( element, firstStylesheet ); } diff --git a/src/utils/editor-styles.test.js b/src/utils/editor-styles.test.js index a06066543..0759f867f 100644 --- a/src/utils/editor-styles.test.js +++ b/src/utils/editor-styles.test.js @@ -49,7 +49,7 @@ const getStyleElement = () => document.getElementById( STYLE_ELEMENT_ID ); describe( 'injectEditorStyles', () => { beforeEach( () => { - getStyleElement()?.remove(); + document.head.innerHTML = ''; } ); it( 'injects a single style element into the document head', () => { @@ -61,6 +61,25 @@ describe( 'injectEditorStyles', () => { expect( element.parentElement ).toBe( document.head ); } ); + // Several selectors tie on specificity between these stylesheets and + // GutenbergKit's own, so the cascade resolves them by source order. + it( 'injects the styles before the existing stylesheets', () => { + const link = document.createElement( 'link' ); + link.rel = 'stylesheet'; + link.href = 'index.css'; + document.head.appendChild( link ); + + injectEditorStyles( false ); + + expect( getStyleElement().nextElementSibling ).toBe( link ); + } ); + + it( 'injects the styles when the head has no stylesheets', () => { + injectEditorStyles( false ); + + expect( getStyleElement().parentElement ).toBe( document.head ); + } ); + it( 'injects the left-to-right stylesheets in cascade order', () => { injectEditorStyles( false ); From a970b6b61b0f00b4cf08507174319a2a25a04d1e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 08:42:20 -0400 Subject: [PATCH 15/21] 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) --- .../editor-toolbar/use-scroll-indicators.js | 27 +++- .../use-scroll-indicators.test.js | 143 ++++++++++++++++++ 2 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 src/components/editor-toolbar/use-scroll-indicators.test.js diff --git a/src/components/editor-toolbar/use-scroll-indicators.js b/src/components/editor-toolbar/use-scroll-indicators.js index c16587b73..ff27652b5 100644 --- a/src/components/editor-toolbar/use-scroll-indicators.js +++ b/src/components/editor-toolbar/use-scroll-indicators.js @@ -1,11 +1,16 @@ /** * Hook to manage scroll indicator state for horizontally scrollable containers. * + * The `canScroll*` properties describe the physical edges of the container + * rather than the start and end of the content, matching the gradients they + * drive. Those are anchored with `left`/`right` and do not flip in a + * right-to-left layout. + * * @param {Object} scrollRef - React ref to the scrollable container element * @return {Object} Scroll state with properties: * - isScrollable: Whether the container has overflow content - * - canScrollLeft: Whether there's content to the left (not at start) - * - canScrollRight: Whether there's content to the right (not at end) + * - canScrollLeft: Whether there's content hidden past the left edge + * - canScrollRight: Whether there's content hidden past the right edge */ import { useState, useEffect, useCallback } from '@wordpress/element'; @@ -29,9 +34,23 @@ export function useScrollIndicators( scrollRef ) { const threshold = 1; const isScrollable = scrollWidth > clientWidth; - const canScrollLeft = scrollLeft > threshold; + + // In a right-to-left container `scrollLeft` is `0` at the right edge + // and grows negative moving left, so the raw value describes distance + // from the start rather than from the left. Normalize to that distance, + // then map it back onto the physical edges the gradients are anchored + // to, which do not flip with the writing direction. + const distanceFromStart = Math.abs( scrollLeft ); + const distanceFromEnd = scrollWidth - clientWidth - distanceFromStart; + + const isRTL = + element.ownerDocument.defaultView.getComputedStyle( element ) + .direction === 'rtl'; + + const canScrollLeft = + ( isRTL ? distanceFromEnd : distanceFromStart ) > threshold; const canScrollRight = - scrollLeft + clientWidth < scrollWidth - threshold; + ( isRTL ? distanceFromStart : distanceFromEnd ) > threshold; setScrollState( { isScrollable, diff --git a/src/components/editor-toolbar/use-scroll-indicators.test.js b/src/components/editor-toolbar/use-scroll-indicators.test.js new file mode 100644 index 000000000..52f2a7818 --- /dev/null +++ b/src/components/editor-toolbar/use-scroll-indicators.test.js @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { useScrollIndicators } from './use-scroll-indicators'; + +/** + * Builds a ref to an element with a stubbed scroll geometry. + * + * jsdom does not lay out content, so `scrollWidth` and `clientWidth` are always + * `0` and the hook would see every container as unscrollable. Define them + * directly to model an overflowing toolbar. + * + * @param {Object} geometry Scroll geometry to simulate. + * @param {number} geometry.scrollLeft Current scroll offset. Negative in a + * right-to-left container. + * @param {number} geometry.scrollWidth Total scrollable width. + * @param {number} geometry.clientWidth Visible width. + * @param {string} geometry.direction Computed `direction` of the container. + * + * @return {Object} A React ref pointing at the element. + */ +function createScrollRef( { + scrollLeft, + scrollWidth = 500, + clientWidth = 200, + direction = 'ltr', +} ) { + const element = document.createElement( 'div' ); + element.dir = direction; + document.body.appendChild( element ); + + Object.defineProperties( element, { + scrollLeft: { value: scrollLeft, configurable: true }, + scrollWidth: { value: scrollWidth, configurable: true }, + clientWidth: { value: clientWidth, configurable: true }, + } ); + + return { current: element }; +} + +describe( 'useScrollIndicators', () => { + it( 'reports an overflowing container as scrollable', () => { + const scrollRef = createScrollRef( { scrollLeft: 0 } ); + const { result } = renderHook( () => useScrollIndicators( scrollRef ) ); + + expect( result.current.isScrollable ).toBe( true ); + } ); + + it( 'reports a container without overflow as not scrollable', () => { + const scrollRef = createScrollRef( { + scrollLeft: 0, + scrollWidth: 200, + clientWidth: 200, + } ); + const { result } = renderHook( () => useScrollIndicators( scrollRef ) ); + + expect( result.current.isScrollable ).toBe( false ); + expect( result.current.canScrollLeft ).toBe( false ); + expect( result.current.canScrollRight ).toBe( false ); + } ); + + describe( 'left-to-right', () => { + it( 'hides the left gradient at the start edge', () => { + const scrollRef = createScrollRef( { scrollLeft: 0 } ); + const { result } = renderHook( () => + useScrollIndicators( scrollRef ) + ); + + expect( result.current.canScrollLeft ).toBe( false ); + expect( result.current.canScrollRight ).toBe( true ); + } ); + + it( 'shows both gradients mid-scroll', () => { + const scrollRef = createScrollRef( { scrollLeft: 150 } ); + const { result } = renderHook( () => + useScrollIndicators( scrollRef ) + ); + + expect( result.current.canScrollLeft ).toBe( true ); + expect( result.current.canScrollRight ).toBe( true ); + } ); + + it( 'hides the right gradient at the end edge', () => { + const scrollRef = createScrollRef( { scrollLeft: 300 } ); + const { result } = renderHook( () => + useScrollIndicators( scrollRef ) + ); + + expect( result.current.canScrollLeft ).toBe( true ); + expect( result.current.canScrollRight ).toBe( false ); + } ); + } ); + + // `scrollLeft` is `0` at the right edge and grows negative moving left, so + // the start edge is on the right and the gradients map to the opposite + // physical edges from their left-to-right counterparts. + describe( 'right-to-left', () => { + it( 'hides the right gradient at the start edge', () => { + const scrollRef = createScrollRef( { + scrollLeft: 0, + direction: 'rtl', + } ); + const { result } = renderHook( () => + useScrollIndicators( scrollRef ) + ); + + expect( result.current.canScrollRight ).toBe( false ); + expect( result.current.canScrollLeft ).toBe( true ); + } ); + + it( 'shows both gradients mid-scroll', () => { + const scrollRef = createScrollRef( { + scrollLeft: -150, + direction: 'rtl', + } ); + const { result } = renderHook( () => + useScrollIndicators( scrollRef ) + ); + + expect( result.current.canScrollLeft ).toBe( true ); + expect( result.current.canScrollRight ).toBe( true ); + } ); + + it( 'hides the left gradient at the end edge', () => { + const scrollRef = createScrollRef( { + scrollLeft: -300, + direction: 'rtl', + } ); + const { result } = renderHook( () => + useScrollIndicators( scrollRef ) + ); + + expect( result.current.canScrollLeft ).toBe( false ); + expect( result.current.canScrollRight ).toBe( true ); + } ); + } ); +} ); From cc384ef7758849d0571b72048aad465e36c00776 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 11:00:57 -0400 Subject: [PATCH 16/21] 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) --- src/utils/editor-environment.js | 6 +++--- src/utils/editor-environment.test.js | 3 +-- src/utils/localization.js | 10 +++++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 8dd0b7023..92737ffde 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -7,7 +7,7 @@ import { getGBKit, logException, } from './bridge'; -import { configureLocale, isRTLLocale } from './localization'; +import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; import { configureAjax } from './ajax'; import { initializeVideoPressAjaxBridge } from './videopress-bridge'; @@ -31,8 +31,8 @@ export async function setUpEditorEnvironment() { await awaitGBKitGlobal(); setLogLevelFromGBKit(); initializeFetchInterceptor(); - await configureLocale(); - injectEditorStyles( isRTLLocale( getGBKit().locale ) ); + const isRTL = await configureLocale(); + injectEditorStyles( isRTL ); await initializeWordPressGlobals(); await configureApiFetch(); const pluginLoadResult = await loadPluginsIfEnabled(); diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index 251a38f63..fc37543ef 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -46,7 +46,6 @@ vi.mock( './editor-loader.js', () => ( { vi.mock( './localization.js', () => ( { configureLocale: vi.fn(), - isRTLLocale: vi.fn( () => false ), } ) ); vi.mock( './api-fetch.js', () => ( { @@ -63,7 +62,7 @@ describe( 'setUpEditorEnvironment', () => { awaitGBKitGlobal.mockResolvedValue( undefined ); getGBKit.mockReturnValue( { plugins: false } ); - configureLocale.mockResolvedValue( undefined ); + configureLocale.mockResolvedValue( false ); initializeWordPressGlobals.mockImplementation( () => {} ); configureApiFetch.mockImplementation( () => {} ); initializeFetchInterceptor.mockImplementation( () => {} ); diff --git a/src/utils/localization.js b/src/utils/localization.js index 2d81e3228..efae94ff5 100644 --- a/src/utils/localization.js +++ b/src/utils/localization.js @@ -34,12 +34,14 @@ const TEXT_DIRECTION_KEY = 'text direction\u0004ltr'; /** * Initializes i18n support for the editor. * - * @return {Promise} A promise that resolves when i18n is initialized. + * @return {Promise} A promise resolving to whether the configured + * locale renders right-to-left, so callers apply the same direction this + * resolved rather than deriving it a second time. */ export async function configureLocale() { const { locale = DEFAULT_LOCALE } = getGBKit(); await loadTranslations( locale ); - configureTextDirection( locale ); + return configureTextDirection( locale ); } /** @@ -80,7 +82,7 @@ export function isRTLLocale( locale ) { * * @param {string} locale The locale in use. * - * @return {void} + * @return {boolean} Whether the locale renders right-to-left. */ function configureTextDirection( locale ) { const isRTL = isRTLLocale( locale ); @@ -98,6 +100,8 @@ function configureTextDirection( locale ) { body?.classList.toggle( 'rtl', isRTL ); debug( `Text direction configured as "${ direction }" for "${ locale }"` ); + + return isRTL; } /** From 615436b5367c77c26e74a71f45ccccf8ed4e890d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 11:01:03 -0400 Subject: [PATCH 17/21] 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) --- .../editor-toolbar/use-scroll-indicators.js | 7 ++++--- .../editor-toolbar/use-scroll-indicators.test.js | 14 +++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/components/editor-toolbar/use-scroll-indicators.js b/src/components/editor-toolbar/use-scroll-indicators.js index ff27652b5..20ca2b7f7 100644 --- a/src/components/editor-toolbar/use-scroll-indicators.js +++ b/src/components/editor-toolbar/use-scroll-indicators.js @@ -43,9 +43,10 @@ export function useScrollIndicators( scrollRef ) { const distanceFromStart = Math.abs( scrollLeft ); const distanceFromEnd = scrollWidth - clientWidth - distanceFromStart; - const isRTL = - element.ownerDocument.defaultView.getComputedStyle( element ) - .direction === 'rtl'; + // Read from the document rather than resolving the element's computed + // style, which this would otherwise force on every scroll frame. The + // direction is set once at startup and fixed for the editor's lifetime. + const isRTL = element.ownerDocument.documentElement.dir === 'rtl'; const canScrollLeft = ( isRTL ? distanceFromEnd : distanceFromStart ) > threshold; diff --git a/src/components/editor-toolbar/use-scroll-indicators.test.js b/src/components/editor-toolbar/use-scroll-indicators.test.js index 52f2a7818..8c48432c9 100644 --- a/src/components/editor-toolbar/use-scroll-indicators.test.js +++ b/src/components/editor-toolbar/use-scroll-indicators.test.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { renderHook } from '@testing-library/react'; /** @@ -21,7 +21,9 @@ import { useScrollIndicators } from './use-scroll-indicators'; * right-to-left container. * @param {number} geometry.scrollWidth Total scrollable width. * @param {number} geometry.clientWidth Visible width. - * @param {string} geometry.direction Computed `direction` of the container. + * @param {string} geometry.direction Text direction the editor renders in. + * Set on the document, which is where the + * hook reads it from. * * @return {Object} A React ref pointing at the element. */ @@ -32,7 +34,7 @@ function createScrollRef( { direction = 'ltr', } ) { const element = document.createElement( 'div' ); - element.dir = direction; + document.documentElement.dir = direction; document.body.appendChild( element ); Object.defineProperties( element, { @@ -45,6 +47,12 @@ function createScrollRef( { } describe( 'useScrollIndicators', () => { + afterEach( () => { + // The direction is set on the document, so reset it to keep a + // right-to-left case from leaking into the next test. + document.documentElement.dir = ''; + } ); + it( 'reports an overflowing container as scrollable', () => { const scrollRef = createScrollRef( { scrollLeft: 0 } ); const { result } = renderHook( () => useScrollIndicators( scrollRef ) ); From 16e41d103272c9df8b8e4ab63df41ac298247815 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 11:01:19 -0400 Subject: [PATCH 18/21] fix(ios): apply the accessibility language once the view is in a hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../Views/EditorAccessibilityLanguage.swift | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift b/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift index 81fed554f..6db1f31d5 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift @@ -37,12 +37,35 @@ private struct EditorAccessibilityLanguageModifier: ViewModifier { private struct AccessibilityLanguageHost: UIViewRepresentable { let language: String - func makeUIView(context: Context) -> UIView { - UIView() + func makeUIView(context: Context) -> AccessibilityLanguageView { + AccessibilityLanguageView() } - func updateUIView(_ uiView: UIView, context: Context) { - uiView.rootHostingView?.accessibilityLanguage = language + func updateUIView(_ uiView: AccessibilityLanguageView, context: Context) { + uiView.language = language + } +} + +/// Applies the language once the view is in a hierarchy. +/// +/// `updateUIView` runs before this view is necessarily attached to a superview, +/// and with no superview the walk up the responder chain finds no hosting +/// controller. Because the language does not change afterward, SwiftUI has no +/// reason to call `updateUIView` again, so a miss there would be permanent. +/// Re-applying on move to a window retries once the hierarchy exists. +private final class AccessibilityLanguageView: UIView { + var language: String? { + didSet { applyLanguage() } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + applyLanguage() + } + + private func applyLanguage() { + guard let language else { return } + rootHostingView?.accessibilityLanguage = language } } From 225f6d2299be4ad419e69e0f917ec32a40428d9d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 11:01:26 -0400 Subject: [PATCH 19/21] 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) --- .../gutenbergkit/SitePreparationActivity.kt | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index f11343e3f..9a7346a80 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -215,7 +215,6 @@ fun SitePreparationScreen( onBrowsePosts: (EditorConfiguration, EditorDependencies?, PostTypeDetails) -> Unit ) { val uiState by viewModel.uiState.collectAsState() - val context = LocalContext.current // Re-read on resume so returning from the system language picker is // noticed. @@ -577,9 +576,22 @@ private fun rememberLocaleOnResume(): Locale { private fun EditorLocaleRow(locale: String?) { val context = LocalContext.current val resolved = locale ?: "en" - val canOpenSettings = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - if (!canOpenSettings) { + // The action is optional even on API 33+ — some devices ship no handler for + // it — so resolve the intent rather than inferring availability from the SDK + // level, which would throw on tap. + val settingsIntent = remember(context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + null + } else { + Intent( + Settings.ACTION_APP_LOCALE_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ).takeIf { it.resolveActivity(context.packageManager) != null } + } + } + + if (settingsIntent == null) { KeyValueRow(key = "Editor Locale", value = resolved) return } @@ -587,14 +599,7 @@ private fun EditorLocaleRow(locale: String?) { Row( modifier = Modifier .fillMaxWidth() - .clickable { - context.startActivity( - Intent( - Settings.ACTION_APP_LOCALE_SETTINGS, - Uri.fromParts("package", context.packageName, null) - ) - ) - } + .clickable { context.startActivity(settingsIntent) } .padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically From 4bff8d8dc6542008e0a79cba2a74cb9707aedc4e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 28 Jul 2026 11:01:40 -0400 Subject: [PATCH 20/21] 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) --- ios/Demo-iOS/Sources/Services/DemoAppLocale.swift | 3 ++- ios/Demo-iOS/Sources/Views/SitePreparationView.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift b/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift index 076c58b3a..4d47adcaf 100644 --- a/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift +++ b/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift @@ -59,7 +59,8 @@ enum DemoAppLocale { return defaultLocale } - private static func isEnglish(_ language: String) -> Bool { + /// Whether a tag's language subtag is English, regardless of region. + static func isEnglish(_ language: String) -> Bool { let normalized = language.replacingOccurrences(of: "_", with: "-") return Locale.Components(identifier: normalized) .languageComponents.languageCode?.identifier.lowercased() == defaultLocale diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index 4bd0c0846..3d7142677 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -131,7 +131,7 @@ struct SitePreparationView: View { // English ships no bundle of its own — it is the editor's source // language — so describe it as the language being used rather than as // a fallback from something else. - if resolved == DemoAppLocale.defaultLocale, normalized.hasPrefix(DemoAppLocale.defaultLocale) { + if resolved == DemoAppLocale.defaultLocale, DemoAppLocale.isEnglish(requested) { return "\(resolved) — \(requested)" } From 9bc49b5ea915a72786e24b9cd6778019746333f2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 29 Jul 2026 13:36:48 -0400 Subject: [PATCH 21/21] refactor(ios): declare the accessibility language once on the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../Sources/EditorViewController.swift | 12 +-- .../BlockInserter/BlockInserterView.swift | 2 - .../Views/EditorAccessibilityLanguage.swift | 85 ------------------- 3 files changed, 7 insertions(+), 92 deletions(-) delete mode 100644 ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 80c51c25d..dd2a8a7f2 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -213,6 +213,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro controller.delegate = self webView.navigationDelegate = controller + // Declares the editor's language to assistive technology, so it selects + // a matching speech voice. The web content declares its own language via + // `documentElement.lang`; this covers the native UI presented alongside + // it. `accessibilityLanguage` is inherited, including across modal + // presentations, so the block inserter and its sheets are covered too. + view.accessibilityLanguage = configuration.locale + // Set up Lockdown Mode monitoring with foreground detection lockdownModeMonitor.setup(presentingViewController: self) @@ -487,11 +494,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro .environment(\.locale, Locale(identifier: configuration.locale)) }) - // The web content declares its language via `documentElement.lang`. - // This is a separate accessibility tree showing the same UI in the same - // locale, so it has to declare the language itself. - host.view.accessibilityLanguage = configuration.locale - context.viewController = host // Set presentation delegate to track dismissal diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift index 6a16d80dc..07f39a1a3 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -72,7 +72,6 @@ struct BlockInserterView: View { insertCameraMedia(media) } .ignoresSafeArea() - .editorAccessibilityLanguage() } .animation(.smooth(duration: 2), value: viewModel.isProcessingMedia) .animation(.snappy, value: inlineSelectedMediaItems.count) @@ -92,7 +91,6 @@ struct BlockInserterView: View { } ) } - .editorAccessibilityLanguage() } .background( GeometryReader { geometry in diff --git a/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift b/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift deleted file mode 100644 index 6db1f31d5..000000000 --- a/ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift +++ /dev/null @@ -1,85 +0,0 @@ -#if canImport(UIKit) -import UIKit -import SwiftUI - -extension View { - /// Declares the editor's language for this view's accessibility tree, so - /// assistive technology selects a matching speech voice. - /// - /// Apply at every presentation boundary. `accessibilityLanguage` is - /// inherited down a view hierarchy, but a sheet is presented as a sibling - /// rather than a descendant, so its content does not inherit the value set - /// on the presenting view. The locale comes from the SwiftUI environment, - /// which *does* cross sheets. - /// - /// - Note: Content rendered out of process — the system photo picker — has - /// its own accessibility tree and cannot be annotated from here. - func editorAccessibilityLanguage() -> some View { - modifier(EditorAccessibilityLanguageModifier()) - } -} - -private struct EditorAccessibilityLanguageModifier: ViewModifier { - @Environment(\.locale) private var locale - - func body(content: Content) -> some View { - content.background( - AccessibilityLanguageHost(language: locale.identifier) - .accessibilityHidden(true) - ) - } -} - -/// Applies `accessibilityLanguage` to the UIKit view hosting this content. -/// -/// SwiftUI has no equivalent modifier — the property exists only on `UIView` -/// and `UIAccessibilityElement`. -private struct AccessibilityLanguageHost: UIViewRepresentable { - let language: String - - func makeUIView(context: Context) -> AccessibilityLanguageView { - AccessibilityLanguageView() - } - - func updateUIView(_ uiView: AccessibilityLanguageView, context: Context) { - uiView.language = language - } -} - -/// Applies the language once the view is in a hierarchy. -/// -/// `updateUIView` runs before this view is necessarily attached to a superview, -/// and with no superview the walk up the responder chain finds no hosting -/// controller. Because the language does not change afterward, SwiftUI has no -/// reason to call `updateUIView` again, so a miss there would be permanent. -/// Re-applying on move to a window retries once the hierarchy exists. -private final class AccessibilityLanguageView: UIView { - var language: String? { - didSet { applyLanguage() } - } - - override func didMoveToWindow() { - super.didMoveToWindow() - applyLanguage() - } - - private func applyLanguage() { - guard let language else { return } - rootHostingView?.accessibilityLanguage = language - } -} - -private extension UIView { - /// The outermost view of the hosting controller presenting this view. - var rootHostingView: UIView? { - var candidate: UIView? = self - while let view = candidate { - if let controller = view.next as? UIViewController { - return controller.view - } - candidate = view.superview - } - return nil - } -} -#endif