From 85d5b9e376a13dbd0fa297b8774e881eb3603b20 Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Wed, 5 Aug 2026 16:03:47 -0400 Subject: [PATCH 1/2] fix(i18n): resolve a deactivated language before the app renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deactivating the language a user is reading left the sidebar in it. The reconciliation added in #1442 runs from `useLanguageOptions`, inside `LanguageToggle`. It corrects `i18n.resolvedLanguage` and emits `languageChange` — but libui's `useTranslation` subscribes in an effect, and React runs effects child-first, so a tree that mounts already stranded fires the correction before the toggle's ancestors have subscribed. They never hear it and keep rendering the deactivated language. The sidebar is the casualty precisely because it renders the toggle. `Layout` mounts the navbar first, so the navbar subscribes in time and updates while the sidebar does not — which is why the symptom names one and not the other. Fixes it where the ordering cannot matter: `_app`'s `beforeLoad` reconciles before any component renders, so every `useTranslation` initialises from the corrected language instead of waiting to be told. The policy itself moves to `resolveActiveLanguage` in schemas/core, next to the authoring-language policy, so the pre-render pass and the live in-session effect decide the same thing rather than each carrying a copy. `useLanguageOptions` keeps its effect: it covers an admin deactivating a language mid-session, where every component is already subscribed and it demonstrably works. Its doc now says which case it can and cannot cover. Co-Authored-By: Claude Opus 5 --- .../src/__tests__/language-toggle.test.tsx | 35 ++++++++++++++++++- apps/web/src/routes/_app/route.tsx | 3 ++ apps/web/src/utils/__tests__/language.test.ts | 29 +++++++++++++-- apps/web/src/utils/language.ts | 21 ++++++++++- .../src/hooks/useLanguageOptions.ts | 19 +++++----- packages/schemas/src/core/core.test.ts | 22 +++++++++++- packages/schemas/src/core/core.ts | 11 ++++++ testing/src/specs/admin-settings.spec.ts | 23 ++++++++++++ 8 files changed, 150 insertions(+), 13 deletions(-) diff --git a/apps/web/src/__tests__/language-toggle.test.tsx b/apps/web/src/__tests__/language-toggle.test.tsx index 66819b47f..57ea40e9b 100644 --- a/apps/web/src/__tests__/language-toggle.test.tsx +++ b/apps/web/src/__tests__/language-toggle.test.tsx @@ -1,7 +1,8 @@ +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { i18n } from '@douglasneuroinformatics/libui/i18n'; import { LanguageToggle } from '@opendatacapture/react-core'; import type { ActiveLanguages } from '@opendatacapture/schemas/core'; -import { cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import '@/services/i18n'; @@ -43,4 +44,36 @@ describe('LanguageToggle', () => { renderToggle(['es']); expect(i18n.resolvedLanguage).toBe('es'); }); + + // The sidebar renders the toggle as a descendant while translating its own strings, so it is the + // ancestor that has to re-render for the fix to be worth anything — asserting `resolvedLanguage` + // alone passed while the sidebar stayed in the deactivated language. + const Ancestor = ({ activeLanguages }: { activeLanguages: ActiveLanguages }) => { + const { t } = useTranslation(); + return ( +
+ {t({ en: 'Dashboard', es: 'Panel de control', fr: 'Tableau' })} + +
+ ); + }; + + it('should re-render an ancestor when a language is deactivated mid-session', () => { + const { rerender } = render(); + act(() => i18n.changeLanguage('es')); + expect(screen.getByTestId('ancestor-label').textContent).toBe('Panel de control'); + + rerender(); + expect(i18n.resolvedLanguage).toBe('en'); + expect(screen.getByTestId('ancestor-label').textContent).toBe('Dashboard'); + }); + + it('should leave a reader alone when the deactivated language was not theirs', () => { + const { rerender } = render(); + act(() => i18n.changeLanguage('fr')); + + rerender(); + expect(i18n.resolvedLanguage).toBe('fr'); + expect(screen.getByTestId('ancestor-label').textContent).toBe('Tableau'); + }); }); diff --git a/apps/web/src/routes/_app/route.tsx b/apps/web/src/routes/_app/route.tsx index 567493151..81913b33b 100644 --- a/apps/web/src/routes/_app/route.tsx +++ b/apps/web/src/routes/_app/route.tsx @@ -6,6 +6,7 @@ import { DisclaimerProvider } from '@/providers/DisclaimerProvider'; import { ForceClearQueryCacheProvider } from '@/providers/ForceClearQueryCacheProvider'; import { WalkthroughProvider } from '@/providers/WalkthroughProvider'; import { useAppStore } from '@/store'; +import { reconcileInterfaceLanguage } from '@/utils/language'; export const Route = createFileRoute('/_app')({ beforeLoad: async ({ context }) => { @@ -17,6 +18,8 @@ export const Route = createFileRoute('/_app')({ if (!accessToken) { throw redirect({ to: '/auth/login' }); } + // Before the tree renders, so no component has to be told after the fact. + reconcileInterfaceLanguage(setupState.activeLanguages); }, component: () => { return ( diff --git a/apps/web/src/utils/__tests__/language.test.ts b/apps/web/src/utils/__tests__/language.test.ts index b7cdf2135..228ecf0e2 100644 --- a/apps/web/src/utils/__tests__/language.test.ts +++ b/apps/web/src/utils/__tests__/language.test.ts @@ -1,7 +1,32 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; import type { LocalizedString } from '@opendatacapture/schemas/core'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; -import { authoredLanguages, omitBlankLanguages } from '../language'; +import { authoredLanguages, omitBlankLanguages, reconcileInterfaceLanguage } from '../language'; + +import '@/services/i18n'; + +describe('reconcileInterfaceLanguage', () => { + beforeEach(() => { + i18n.changeLanguage('en'); + }); + + it('should move a reader off a language the instance no longer offers', () => { + i18n.changeLanguage('es'); + expect(reconcileInterfaceLanguage(['en', 'fr'])).toBe(true); + expect(i18n.resolvedLanguage).toBe('en'); + }); + + it('should leave a reader on a language the instance still offers', () => { + i18n.changeLanguage('fr'); + expect(reconcileInterfaceLanguage(['en', 'fr'])).toBe(false); + expect(i18n.resolvedLanguage).toBe('fr'); + }); + + it('should report no change when nothing moved, so it does not notify every translated component', () => { + expect(reconcileInterfaceLanguage(['en', 'es', 'fr'])).toBe(false); + }); +}); describe('authoredLanguages', () => { it('should list only the languages with non-blank content, in a stable order', () => { diff --git a/apps/web/src/utils/language.ts b/apps/web/src/utils/language.ts index e3fd10b63..f2e7ea982 100644 --- a/apps/web/src/utils/language.ts +++ b/apps/web/src/utils/language.ts @@ -1,5 +1,7 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; import { LANGUAGE_LABELS, LANGUAGES } from '@opendatacapture/react-core'; -import type { Language, LocalizedString } from '@opendatacapture/schemas/core'; +import { resolveActiveLanguage } from '@opendatacapture/schemas/core'; +import type { ActiveLanguages, Language, LocalizedString } from '@opendatacapture/schemas/core'; /** Blank text is absent text: content authored as `''` is a language the author left empty. */ const isPresent = (value: null | TValue | undefined): value is TValue => @@ -31,4 +33,21 @@ export const authoredLanguages = (value: LocalizedString | null | undefined): La export const omitBlankLanguages = (value: LocalizedString | null | undefined): LocalizedString => Object.fromEntries(authoredLanguages(value).map((code) => [code, value?.[code]])); +/** + * Move a reader off a language their instance no longer offers, and report whether it moved them. + * + * Called before the app renders rather than from a component: `changeLanguage` notifies only the + * components already subscribed, libui's `useTranslation` subscribes in an effect, and effects run + * child-first — so a correction made after mount never reaches the ancestors of whatever made it. + * The sidebar renders the language toggle, so the sidebar is what a late correction leaves behind. + */ +export const reconcileInterfaceLanguage = (activeLanguages: ActiveLanguages): boolean => { + const language = resolveActiveLanguage(i18n.resolvedLanguage, activeLanguages); + if (language === i18n.resolvedLanguage) { + return false; + } + i18n.changeLanguage(language); + return true; +}; + export { LANGUAGE_LABELS, LANGUAGES }; diff --git a/packages/react-core/src/hooks/useLanguageOptions.ts b/packages/react-core/src/hooks/useLanguageOptions.ts index 525122cb8..29b224f64 100644 --- a/packages/react-core/src/hooks/useLanguageOptions.ts +++ b/packages/react-core/src/hooks/useLanguageOptions.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { resolveActiveLanguage } from '@opendatacapture/schemas/core'; import type { ActiveLanguages } from '@opendatacapture/schemas/core'; import { toLanguageToggleOptions } from '../utils/language'; @@ -8,21 +9,23 @@ import { toLanguageToggleOptions } from '../utils/language'; /** * The languages an instance offers, as options for libui's `LanguageToggle`. * - * Deactivating a language would otherwise strand every user already reading in it: their strings - * still resolve, but the toggle no longer lists it, so they have no way back. Reconciling here — - * rather than where an admin flips the setting — moves whoever is affected on their next load, - * not just the admin who made the change. + * The effect covers an admin deactivating a language **during** a session: every component is + * subscribed to `languageChange` by then, so they all re-render. It cannot cover a tree that + * mounts already stranded — effects run child-first, so this fires before the ancestors rendering + * the toggle have subscribed, and they would keep the deactivated language. A host resolves that + * case before it renders (`apps/web` in the `_app` route's `beforeLoad`; `apps/gateway` picks the + * language server-side from the same set), which is why this only has to handle the live change. */ export const useLanguageOptions = (activeLanguages: ActiveLanguages) => { const { changeLanguage, resolvedLanguage } = useTranslation(); - const isActive = activeLanguages.includes(resolvedLanguage); + const reconciled = resolveActiveLanguage(resolvedLanguage, activeLanguages); useEffect(() => { - if (!isActive) { - changeLanguage(activeLanguages[0]); + if (reconciled !== resolvedLanguage) { + changeLanguage(reconciled); } - }, [isActive, activeLanguages]); + }, [reconciled, resolvedLanguage]); return toLanguageToggleOptions(activeLanguages); }; diff --git a/packages/schemas/src/core/core.test.ts b/packages/schemas/src/core/core.test.ts index 3520c3a1a..953a80fc4 100644 --- a/packages/schemas/src/core/core.test.ts +++ b/packages/schemas/src/core/core.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { $ActiveLanguages, $LocalizedString, LANGUAGES, toInstrumentAuthoringLanguage } from './core.js'; +import { + $ActiveLanguages, + $LocalizedString, + LANGUAGES, + resolveActiveLanguage, + toInstrumentAuthoringLanguage +} from './core.js'; describe('$ActiveLanguages', () => { it.each([[['en']], [['es']], [['en', 'fr']], [['en', 'es', 'fr']]])( @@ -27,6 +33,20 @@ describe('$ActiveLanguages', () => { }); }); +describe('resolveActiveLanguage', () => { + it('should keep a reader on their language while the instance still offers it', () => { + expect(resolveActiveLanguage('fr', ['en', 'fr'])).toBe('fr'); + }); + + it('should move a reader off a deactivated language, which the toggle no longer offers a way out of', () => { + expect(resolveActiveLanguage('es', ['en', 'fr'])).toBe('en'); + }); + + it('should fall back to the first offered language, so the result does not depend on click order', () => { + expect(resolveActiveLanguage('en', ['fr', 'es'])).toBe('fr'); + }); +}); + describe('$LocalizedString', () => { it('should accept an entry for every interface language', () => { expect($LocalizedString.safeParse({ en: 'Hello', es: 'Hola', fr: 'Bonjour' }).success).toBe(true); diff --git a/packages/schemas/src/core/core.ts b/packages/schemas/src/core/core.ts index d0a2ea1ac..6e4d8b15c 100644 --- a/packages/schemas/src/core/core.ts +++ b/packages/schemas/src/core/core.ts @@ -82,6 +82,17 @@ export const $ActiveLanguages = z.tuple([$Language], $Language); /** The languages an instance offers before an admin has chosen, and the fallback for one saved before this setting existed. */ export const DEFAULT_ACTIVE_LANGUAGES: ActiveLanguages = ['en', 'fr']; +/** + * The language a reader should end up in, given the set their instance offers. A reader on a + * language that has since been deactivated falls back to the first active one — the toggle no + * longer lists theirs, so leaving them on it strands them with no way out. + * + * This is the one place that policy is decided; both the moment it is applied — before the app + * renders, and again whenever an admin changes the set mid-session — resolve through here. + */ +export const resolveActiveLanguage = (language: Language, activeLanguages: ActiveLanguages): Language => + activeLanguages.includes(language) ? language : activeLanguages[0]; + /** * A string authored in each of the application's languages. Every field is nullish so content * may target a single language, and nullish rather than optional to match Prisma's diff --git a/testing/src/specs/admin-settings.spec.ts b/testing/src/specs/admin-settings.spec.ts index e74430e62..903d3240b 100644 --- a/testing/src/specs/admin-settings.spec.ts +++ b/testing/src/specs/admin-settings.spec.ts @@ -65,6 +65,29 @@ test.describe('admin settings', () => { await settingsPage.activeLanguageCheckbox('fr').click(); expect((await restored).ok()).toBe(true); await expect(page.getByTestId('sidebar').getByTestId('language-toggle')).toBeVisible(); + + // Deactivating the language the reader is currently in. The sidebar is the casualty when this + // goes wrong: it renders the toggle, so it is the ancestor a correction made from the toggle + // cannot reach. `Iniciar una sesión` is a namespace string, translated on any branch. + const sidebar = page.getByTestId('sidebar'); + const activated = waitForSetupPatch(page); + await settingsPage.activeLanguageCheckbox('es').click(); + expect((await activated).ok()).toBe(true); + + await sidebar.getByTestId('language-toggle').getByRole('button').click(); + await page.getByRole('menuitem', { name: 'Español' }).click(); + await expect(sidebar).toContainText('Iniciar una sesión'); + + const deactivatedSpanish = waitForSetupPatch(page); + await settingsPage.activeLanguageCheckbox('es').click(); + expect((await deactivatedSpanish).ok()).toBe(true); + + await expect(sidebar).toContainText('Start Session'); + await expect(sidebar).not.toContainText('Iniciar una sesión'); + + // And on a fresh mount, where the correction has to already have happened before render. + await page.reload(); + await expect(sidebar).toContainText('Start Session'); }); test('should apply the group switcher position preference immediately', async ({ getPageModel }) => { From d1560c0a69a614dacdb57286282087854f0f0b10 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 6 Aug 2026 23:28:18 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(i18n):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20unify=20gateway=20language=20policy,=20move=20side-?= =?UTF-8?q?effect=20to=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route gateway's language resolution through resolveActiveLanguage so the doc comment's "one place" claim is accurate. Move reconcileInterfaceLanguage from utils/ (pure helpers) to services/i18n.ts (side-effect singletons) per apps/web AGENTS.md. Co-Authored-By: Claude Opus 4.6 --- apps/gateway/src/routers/root.router.ts | 9 +++--- .../reconcile-interface-language.test.ts | 28 ++++++++++++++++++ apps/web/src/routes/_app/route.tsx | 2 +- apps/web/src/services/i18n.ts | 19 ++++++++++++ apps/web/src/utils/__tests__/language.test.ts | 29 ++----------------- apps/web/src/utils/language.ts | 21 +------------- 6 files changed, 55 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/__tests__/reconcile-interface-language.test.ts diff --git a/apps/gateway/src/routers/root.router.ts b/apps/gateway/src/routers/root.router.ts index 1ede9cb45..09800cd07 100644 --- a/apps/gateway/src/routers/root.router.ts +++ b/apps/gateway/src/routers/root.router.ts @@ -1,4 +1,4 @@ -import { $Language } from '@opendatacapture/schemas/core'; +import { $Language, resolveActiveLanguage } from '@opendatacapture/schemas/core'; import { $InstrumentBundleContainer } from '@opendatacapture/schemas/instrument'; import { Router } from 'express'; @@ -45,10 +45,9 @@ router.get( // resolve the same language; anything else renders the page in English and then swaps it. const { activeLanguages } = assignment; const requestedLanguage = $Language.safeParse(req.query.lang); - const language = - requestedLanguage.success && activeLanguages.includes(requestedLanguage.data) - ? requestedLanguage.data - : activeLanguages[0]; + const language = requestedLanguage.success + ? resolveActiveLanguage(requestedLanguage.data, activeLanguages) + : activeLanguages[0]; const token = generateToken(assignment.id); const html = res.locals.loadRoot({ diff --git a/apps/web/src/__tests__/reconcile-interface-language.test.ts b/apps/web/src/__tests__/reconcile-interface-language.test.ts new file mode 100644 index 000000000..c899fb0b9 --- /dev/null +++ b/apps/web/src/__tests__/reconcile-interface-language.test.ts @@ -0,0 +1,28 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { reconcileInterfaceLanguage } from '@/services/i18n'; + +import '@/services/i18n'; + +describe('reconcileInterfaceLanguage', () => { + beforeEach(() => { + i18n.changeLanguage('en'); + }); + + it('should move a reader off a language the instance no longer offers', () => { + i18n.changeLanguage('es'); + expect(reconcileInterfaceLanguage(['en', 'fr'])).toBe(true); + expect(i18n.resolvedLanguage).toBe('en'); + }); + + it('should leave a reader on a language the instance still offers', () => { + i18n.changeLanguage('fr'); + expect(reconcileInterfaceLanguage(['en', 'fr'])).toBe(false); + expect(i18n.resolvedLanguage).toBe('fr'); + }); + + it('should report no change when nothing moved, so it does not notify every translated component', () => { + expect(reconcileInterfaceLanguage(['en', 'es', 'fr'])).toBe(false); + }); +}); diff --git a/apps/web/src/routes/_app/route.tsx b/apps/web/src/routes/_app/route.tsx index 81913b33b..2e2d635bc 100644 --- a/apps/web/src/routes/_app/route.tsx +++ b/apps/web/src/routes/_app/route.tsx @@ -6,7 +6,7 @@ import { DisclaimerProvider } from '@/providers/DisclaimerProvider'; import { ForceClearQueryCacheProvider } from '@/providers/ForceClearQueryCacheProvider'; import { WalkthroughProvider } from '@/providers/WalkthroughProvider'; import { useAppStore } from '@/store'; -import { reconcileInterfaceLanguage } from '@/utils/language'; +import { reconcileInterfaceLanguage } from '@/services/i18n'; export const Route = createFileRoute('/_app')({ beforeLoad: async ({ context }) => { diff --git a/apps/web/src/services/i18n.ts b/apps/web/src/services/i18n.ts index 808f6e96d..365f93dfb 100644 --- a/apps/web/src/services/i18n.ts +++ b/apps/web/src/services/i18n.ts @@ -2,6 +2,8 @@ /* eslint-disable @typescript-eslint/no-namespace */ import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { resolveActiveLanguage } from '@opendatacapture/schemas/core'; +import type { ActiveLanguages } from '@opendatacapture/schemas/core'; import auth from '../translations/auth.json'; import common from '../translations/common.json'; @@ -49,4 +51,21 @@ i18n.init({ } }); +/** + * Move a reader off a language their instance no longer offers, and report whether it moved them. + * + * Called before the app renders rather than from a component: `changeLanguage` notifies only the + * components already subscribed, libui's `useTranslation` subscribes in an effect, and effects run + * child-first — so a correction made after mount never reaches the ancestors of whatever made it. + * The sidebar renders the language toggle, so the sidebar is what a late correction leaves behind. + */ +export const reconcileInterfaceLanguage = (activeLanguages: ActiveLanguages): boolean => { + const language = resolveActiveLanguage(i18n.resolvedLanguage, activeLanguages); + if (language === i18n.resolvedLanguage) { + return false; + } + i18n.changeLanguage(language); + return true; +}; + export default i18n; diff --git a/apps/web/src/utils/__tests__/language.test.ts b/apps/web/src/utils/__tests__/language.test.ts index 228ecf0e2..b7cdf2135 100644 --- a/apps/web/src/utils/__tests__/language.test.ts +++ b/apps/web/src/utils/__tests__/language.test.ts @@ -1,32 +1,7 @@ -import { i18n } from '@douglasneuroinformatics/libui/i18n'; import type { LocalizedString } from '@opendatacapture/schemas/core'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; -import { authoredLanguages, omitBlankLanguages, reconcileInterfaceLanguage } from '../language'; - -import '@/services/i18n'; - -describe('reconcileInterfaceLanguage', () => { - beforeEach(() => { - i18n.changeLanguage('en'); - }); - - it('should move a reader off a language the instance no longer offers', () => { - i18n.changeLanguage('es'); - expect(reconcileInterfaceLanguage(['en', 'fr'])).toBe(true); - expect(i18n.resolvedLanguage).toBe('en'); - }); - - it('should leave a reader on a language the instance still offers', () => { - i18n.changeLanguage('fr'); - expect(reconcileInterfaceLanguage(['en', 'fr'])).toBe(false); - expect(i18n.resolvedLanguage).toBe('fr'); - }); - - it('should report no change when nothing moved, so it does not notify every translated component', () => { - expect(reconcileInterfaceLanguage(['en', 'es', 'fr'])).toBe(false); - }); -}); +import { authoredLanguages, omitBlankLanguages } from '../language'; describe('authoredLanguages', () => { it('should list only the languages with non-blank content, in a stable order', () => { diff --git a/apps/web/src/utils/language.ts b/apps/web/src/utils/language.ts index f2e7ea982..e3fd10b63 100644 --- a/apps/web/src/utils/language.ts +++ b/apps/web/src/utils/language.ts @@ -1,7 +1,5 @@ -import { i18n } from '@douglasneuroinformatics/libui/i18n'; import { LANGUAGE_LABELS, LANGUAGES } from '@opendatacapture/react-core'; -import { resolveActiveLanguage } from '@opendatacapture/schemas/core'; -import type { ActiveLanguages, Language, LocalizedString } from '@opendatacapture/schemas/core'; +import type { Language, LocalizedString } from '@opendatacapture/schemas/core'; /** Blank text is absent text: content authored as `''` is a language the author left empty. */ const isPresent = (value: null | TValue | undefined): value is TValue => @@ -33,21 +31,4 @@ export const authoredLanguages = (value: LocalizedString | null | undefined): La export const omitBlankLanguages = (value: LocalizedString | null | undefined): LocalizedString => Object.fromEntries(authoredLanguages(value).map((code) => [code, value?.[code]])); -/** - * Move a reader off a language their instance no longer offers, and report whether it moved them. - * - * Called before the app renders rather than from a component: `changeLanguage` notifies only the - * components already subscribed, libui's `useTranslation` subscribes in an effect, and effects run - * child-first — so a correction made after mount never reaches the ancestors of whatever made it. - * The sidebar renders the language toggle, so the sidebar is what a late correction leaves behind. - */ -export const reconcileInterfaceLanguage = (activeLanguages: ActiveLanguages): boolean => { - const language = resolveActiveLanguage(i18n.resolvedLanguage, activeLanguages); - if (language === i18n.resolvedLanguage) { - return false; - } - i18n.changeLanguage(language); - return true; -}; - export { LANGUAGE_LABELS, LANGUAGES };