diff --git a/docs/decisions/ADR-0054-i18n-vue-i18n-surface-by-surface.md b/docs/decisions/ADR-0054-i18n-vue-i18n-surface-by-surface.md
index 357f122ad..aa196c990 100644
--- a/docs/decisions/ADR-0054-i18n-vue-i18n-surface-by-surface.md
+++ b/docs/decisions/ADR-0054-i18n-vue-i18n-surface-by-surface.md
@@ -158,7 +158,11 @@ that preference is held by a Pinia store persisting to `localStorage` (`paperThe
`td.paper.mode.v2`). Language follows the identical pattern: `store/localeStore.ts`, key
`td.locale.v1`, with the same validate-on-read / default-on-garbage discipline, and the same
"apply" action that pushes the value into the runtime (here: `i18n.global.locale` plus
-``, mirroring how `paperThemeStore` pushes a class onto `
`).
+``, mirroring how `paperThemeStore` pushes a class onto ``). Since `#2003`
+(PR `#2626`'s sibling `#2633`) the push is commit-after-load: the store loads the lazy catalog first
+and only then commits the runtime locale and `` together, exposing pending and failed
+state to the picker; a failed load keeps the previous language visible, and startup waits for the
+restore for at most a bounded budget before mounting in English.
Language is a *client display* preference, not account data: it is not sent to or stored by the
backend, and there is no server-side user-preference table for it to live in. If a future
diff --git a/frontend/taskdeck-web/src/i18n/index.ts b/frontend/taskdeck-web/src/i18n/index.ts
index 210fda99a..899241a2f 100644
--- a/frontend/taskdeck-web/src/i18n/index.ts
+++ b/frontend/taskdeck-web/src/i18n/index.ts
@@ -23,8 +23,9 @@ import en from '../locales/en'
* code-split behind `ensureLocaleMessages()` and fetched the first time the
* user selects them, so adding translated surfaces no longer spends the
* total-JS budget for users who never leave English. While a catalog is in
- * flight (or if its chunk fails to load), the silent-fallback semantics above
- * already describe what the user sees: English.
+ * flight (or if its chunk fails to load), the locale store keeps the last
+ * committed language active; the low-level runtime still has English as its
+ * fallback for keys that are not present in the committed catalog.
*/
export const SUPPORTED_LOCALES = ['en', 'it', 'es'] as const
@@ -90,7 +91,8 @@ const loaded = new Set([DEFAULT_LOCALE])
* Fetch and register a locale's catalog, once. Resolves `true` when the
* catalog is available (already or newly), `false` when the chunk failed to
* load — in which case the failure is forgotten so a later switch retries,
- * and the user simply stays on English fallback in the meantime. Never throws.
+ * and callers can retain their previous committed language in the meantime.
+ * Never throws.
*/
export function ensureLocaleMessages(locale: SupportedLocale): Promise {
if (loaded.has(locale)) return Promise.resolve(true)
diff --git a/frontend/taskdeck-web/src/locales/en/settings.ts b/frontend/taskdeck-web/src/locales/en/settings.ts
index b19e3314e..2b664c031 100644
--- a/frontend/taskdeck-web/src/locales/en/settings.ts
+++ b/frontend/taskdeck-web/src/locales/en/settings.ts
@@ -40,5 +40,7 @@ export default {
label: 'Language',
hint: 'Taskdeck is being translated one surface at a time. Anything not translated yet stays in English. Options marked "Machine-translated" have not yet been reviewed by a native speaker.',
machineTranslated: 'Machine-translated',
+ loading: 'Loading {locale}…',
+ loadFailed: "Couldn’t load {locale}. {activeLocale} remains active. Try again.",
},
}
diff --git a/frontend/taskdeck-web/src/locales/es/settings.ts b/frontend/taskdeck-web/src/locales/es/settings.ts
index 63f2a55b2..8a0f4fc5b 100644
--- a/frontend/taskdeck-web/src/locales/es/settings.ts
+++ b/frontend/taskdeck-web/src/locales/es/settings.ts
@@ -34,5 +34,7 @@ export default {
label: 'Idioma',
hint: 'Taskdeck se traduce superficie a superficie. Lo que aún no está traducido se queda en inglés. Las opciones marcadas como "Traducción automática" aún no han sido revisadas por un hablante nativo.',
machineTranslated: 'Traducción automática',
+ loading: 'Cargando {locale}…',
+ loadFailed: 'No se pudo cargar {locale}. Se mantiene {activeLocale}. Inténtalo de nuevo.',
},
}
diff --git a/frontend/taskdeck-web/src/locales/it/settings.ts b/frontend/taskdeck-web/src/locales/it/settings.ts
index 6c9fa6970..14810b40d 100644
--- a/frontend/taskdeck-web/src/locales/it/settings.ts
+++ b/frontend/taskdeck-web/src/locales/it/settings.ts
@@ -34,5 +34,7 @@ export default {
label: 'Lingua',
hint: 'Taskdeck viene tradotto una superficie alla volta. Ciò che non è ancora tradotto resta in inglese. Le opzioni contrassegnate come "Traduzione automatica" non sono ancora state riviste da un madrelingua.',
machineTranslated: 'Traduzione automatica',
+ loading: 'Caricamento di {locale}…',
+ loadFailed: 'Impossibile caricare {locale}. {activeLocale} resta attivo. Riprova.',
},
}
diff --git a/frontend/taskdeck-web/src/main.ts b/frontend/taskdeck-web/src/main.ts
index 0ec22822b..6f5e18945 100644
--- a/frontend/taskdeck-web/src/main.ts
+++ b/frontend/taskdeck-web/src/main.ts
@@ -13,6 +13,7 @@ import './paper-legacy-bridge.css'
import {
installVueErrorHandler,
installWindowErrorListeners,
+ logError,
} from './utils/errorReporting'
const app = createApp(App)
@@ -22,33 +23,49 @@ app.use(pinia)
app.use(i18n)
app.use(router)
-// Restore the persisted language preference (ADR-0054 §7) and push it into the
-// i18n runtime + ``. Statically imported and called synchronously on
-// purpose: this must happen after `app.use(pinia)` (the store needs an active
-// Pinia) and BEFORE `app.mount` below, so the first paint is already in the
-// user's language instead of flashing English. A dynamic import would resolve
-// after mount and produce exactly that flash.
-useLocaleStore(pinia).apply()
-
// Install global crash-prevention hooks before mount so early errors are
// captured. The Vue handler is the top-level backstop for render/lifecycle
// errors; the window listeners catch async rejections and non-Vue errors.
installVueErrorHandler(app)
installWindowErrorListeners()
-app.mount('#app')
+// Restore the persisted language preference (ADR-0054 §7) before mount. The
+// store keeps the committed locale on English until a lazy catalog is ready,
+// so waiting here preserves the no-flash guarantee without briefly claiming a
+// language whose messages are unavailable. Catalog failure is reported by the
+// mounted picker; it must not prevent the app from starting in English. The
+// wait is bounded: a lazy catalog request that stalls instead of failing must
+// not hold first paint hostage, so after LOCALE_RESTORE_MOUNT_BUDGET_MS the app
+// mounts in English and the store commits the locale atomically if the catalog
+// arrives later.
+const LOCALE_RESTORE_MOUNT_BUDGET_MS = 1500
-// Initialize telemetry after mount (non-blocking, opt-in).
-// This restores user consent from localStorage and fetches server config.
-// No events are emitted unless the user has explicitly opted in.
-import('./store/telemetryStore').then(({ useTelemetryStore }) => {
- const telemetry = useTelemetryStore()
- void telemetry.initialize()
-})
+const localeRestore = useLocaleStore(pinia)
+ .apply()
+ .catch((error: unknown) => {
+ logError('[main] locale restore rejected before mount', error)
+ })
-// Initialize analytics script watcher after mount (non-blocking).
-// This watches the telemetry store's analyticsConfig and injects/removes
-// the analytics script based on user consent and server configuration.
-import('./composables/useAnalyticsScript').then(({ initAnalyticsScriptWatcher }) => {
- initAnalyticsScriptWatcher()
+const mountBudget = new Promise((resolve) => {
+ window.setTimeout(resolve, LOCALE_RESTORE_MOUNT_BUDGET_MS)
})
+
+void Promise.race([localeRestore, mountBudget])
+ .finally(() => {
+ app.mount('#app')
+
+ // Initialize telemetry after mount (non-blocking, opt-in).
+ // This restores user consent from localStorage and fetches server config.
+ // No events are emitted unless the user has explicitly opted in.
+ import('./store/telemetryStore').then(({ useTelemetryStore }) => {
+ const telemetry = useTelemetryStore()
+ void telemetry.initialize()
+ })
+
+ // Initialize analytics script watcher after mount (non-blocking).
+ // This watches the telemetry store's analyticsConfig and injects/removes
+ // the analytics script based on user consent and server configuration.
+ import('./composables/useAnalyticsScript').then(({ initAnalyticsScriptWatcher }) => {
+ initAnalyticsScriptWatcher()
+ })
+ })
diff --git a/frontend/taskdeck-web/src/store/localeStore.ts b/frontend/taskdeck-web/src/store/localeStore.ts
index 0021afe57..44d35a527 100644
--- a/frontend/taskdeck-web/src/store/localeStore.ts
+++ b/frontend/taskdeck-web/src/store/localeStore.ts
@@ -11,10 +11,11 @@ import {
/**
* Language preference (ADR-0054 §7).
*
- * Deliberately mirrors `paperThemeStore`: a Pinia store whose value is read
- * from and written to `localStorage`, validated on read, defaulted on garbage,
- * with an `apply()` action that pushes the value into the runtime — there, a
- * class on ``; here, the vue-i18n locale plus ``.
+ * Deliberately mirrors `paperThemeStore`: a Pinia store whose preferred value
+ * is read from and written to `localStorage`, validated on read, defaulted on
+ * garbage, with an `apply()` action that pushes a loaded preference into the
+ * runtime — there, a class on ``; here, the vue-i18n locale plus
+ * ``.
*
* This is a CLIENT DISPLAY preference. It is not sent to the backend and there
* is no server-side user-preference row for it. If it ever needs to follow the
@@ -46,57 +47,118 @@ function applyLocale(locale: SupportedLocale) {
}
}
+function persistLocale(locale: SupportedLocale) {
+ try {
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(STORAGE_KEY, locale)
+ }
+ } catch {
+ // ignore quota / private-mode failures — the in-memory preference still applies
+ }
+}
+
+// A catalog response is allowed to commit only if it belongs to the most
+// recent request. This is deliberately separate from the catalog loader's
+// per-locale in-flight deduplication: two different locale requests can be in
+// flight at once, and the older one must not overwrite the newer choice.
+let latestRequestGeneration = 0
+
export const useLocaleStore = defineStore('locale', {
state: () => ({
- locale: readStoredLocale() as SupportedLocale,
+ // `locale` is the committed/displayed locale. The stored preference is a
+ // desired locale until its catalog has loaded successfully.
+ locale: DEFAULT_LOCALE as SupportedLocale,
+ preferredLocale: readStoredLocale() as SupportedLocale,
+ pendingLocale: null as SupportedLocale | null,
+ failedLocale: null as SupportedLocale | null,
}),
getters: {
available(): ReadonlyArray {
return SUPPORTED_LOCALES
},
+ isPending: (state): boolean => state.pendingLocale !== null,
},
actions: {
/**
- * Push the current locale into the i18n runtime and ``.
- * Idempotent. Flip FIRST: `it`/`es` catalogs are code-split (#1858), and
- * until the chunk arrives the silent en-fallback shows English — the same
- * thing the user already sees for any not-yet-extracted surface;
- * `setLocaleMessage` is reactive, so translations appear when it lands.
+ * Restore the stored preference before the first app mount. A non-English
+ * catalog is loaded before the preference is committed, so the first
+ * mounted render cannot claim a language whose messages are unavailable.
*
- * If the chunk FAILS, the runtime, ``, and the in-memory store
- * value are all reverted to English so the UI never claims a language it
- * is not rendering (the flip-first window is bounded by the request; a
- * failure is not). The persisted preference is deliberately KEPT: a
- * transient failure (offline, stale deployment mid-swap) self-heals on the
- * next boot or the next manual switch instead of silently discarding the
- * user's choice. The returned promise settles after any revert; callers
- * that only care about the switch itself may ignore it.
+ * If the chunk fails, the committed runtime locale remains usable and the
+ * persisted preference is deliberately kept so a transient failure
+ * (offline, stale deployment mid-swap) retries on the next boot or manual
+ * switch. `failedLocale` lets the mounted picker report that target
+ * honestly instead of silently falling back.
*/
apply(): Promise {
- const target = this.locale
- applyLocale(target)
- return ensureLocaleMessages(target).then((ok) => {
- const stillWanted = this.locale === target && i18n.global.locale.value === target
- if (!ok && stillWanted && target !== DEFAULT_LOCALE) {
- this.locale = DEFAULT_LOCALE
- applyLocale(DEFAULT_LOCALE)
- }
- })
+ const target = this.preferredLocale
+ const generation = ++latestRequestGeneration
+
+ this.failedLocale = null
+ applyLocale(this.locale)
+
+ if (target === this.locale) {
+ this.pendingLocale = null
+ return Promise.resolve()
+ }
+
+ this.pendingLocale = target
+ return Promise.resolve()
+ .then(() => ensureLocaleMessages(target))
+ .catch(() => false)
+ .then((ok) => {
+ if (generation !== latestRequestGeneration) return
+
+ this.pendingLocale = null
+ if (ok) {
+ this.locale = target
+ applyLocale(target)
+ return
+ }
+
+ applyLocale(this.locale)
+ this.failedLocale = target
+ })
},
setLocale(locale: SupportedLocale): Promise {
// Guard the public entry point too: a bad value here would otherwise be
// persisted and only rejected on the NEXT read, leaving the running app
// on a locale with no catalog.
if (!isSupportedLocale(locale)) return Promise.resolve()
- this.locale = locale
- try {
- if (typeof window !== 'undefined') {
- window.localStorage.setItem(STORAGE_KEY, locale)
- }
- } catch {
- // ignore quota / private-mode failures — the in-memory switch still applies
+
+ this.preferredLocale = locale
+ persistLocale(locale)
+
+ const generation = ++latestRequestGeneration
+ this.failedLocale = null
+ applyLocale(this.locale)
+
+ // Selecting the committed language again cancels an older pending
+ // request and restores a truthful, idle picker immediately.
+ if (locale === this.locale) {
+ this.pendingLocale = null
+ return Promise.resolve()
}
- return this.apply()
+
+ this.pendingLocale = locale
+ return Promise.resolve()
+ .then(() => ensureLocaleMessages(locale))
+ .catch(() => false)
+ .then((ok) => {
+ if (generation !== latestRequestGeneration) return
+
+ this.pendingLocale = null
+ if (ok) {
+ this.locale = locale
+ applyLocale(locale)
+ return
+ }
+
+ // Do not infer that a failed chunk left a usable catalog. Keep the
+ // last committed language and report the requested target to the UI.
+ applyLocale(this.locale)
+ this.failedLocale = locale
+ })
},
},
})
diff --git a/frontend/taskdeck-web/src/tests/i18n/lazyLocales.spec.ts b/frontend/taskdeck-web/src/tests/i18n/lazyLocales.spec.ts
index 9b341b816..307e05946 100644
--- a/frontend/taskdeck-web/src/tests/i18n/lazyLocales.spec.ts
+++ b/frontend/taskdeck-web/src/tests/i18n/lazyLocales.spec.ts
@@ -7,11 +7,11 @@ import { useLocaleStore } from '../../store/localeStore'
*
* Only `en` is registered at module load; `it`/`es` arrive through
* `ensureLocaleMessages()`. The global test setup preloads every catalog (so
- * the wider suite can keep flipping locales synchronously), which means this
- * spec asserts the CONTRACT of the loader — idempotence, registration, and the
- * flip-first switch semantics — not the pre-load empty state. The "en-only in
- * the initial chunk" claim itself is a build-graph property, proven by the
- * bundle-budget CI gate and the emitted per-locale chunks, not assertable here.
+ * the wider suite can keep checking translated rendering), which means this
+ * spec asserts the loader contract plus the atomic switch boundary. The
+ * "en-only in the initial chunk" claim itself is a build-graph property,
+ * proven by the bundle-budget CI gate and the emitted per-locale chunks, not
+ * assertable here.
*/
describe('lazy locale catalogs (#1858)', () => {
@@ -30,17 +30,24 @@ describe('lazy locale catalogs (#1858)', () => {
expect(second).toBe(true)
})
- it('setLocale flips the runtime locale synchronously and returns the catalog promise', async () => {
+ it('keeps the committed locale until the catalog promise settles', async () => {
const store = useLocaleStore()
const pending = store.setLocale('es')
- // Flip-first: the locale is live before the catalog promise settles, so a
- // slow chunk shows English fallback rather than blocking the switch.
- expect(i18n.global.locale.value).toBe('es')
+ // A slow chunk must not make the UI claim Spanish while the old language
+ // is still rendering.
+ expect(store.locale).toBe('en')
+ expect(store.pendingLocale).toBe('es')
+ expect(i18n.global.locale.value).toBe('en')
expect(pending).toBeInstanceOf(Promise)
await pending
+ expect(store.locale).toBe('es')
+ expect(store.pendingLocale).toBeNull()
+ expect(i18n.global.locale.value).toBe('es')
+
await store.setLocale('en')
+ expect(store.locale).toBe('en')
expect(i18n.global.locale.value).toBe('en')
})
})
diff --git a/frontend/taskdeck-web/src/tests/store/localeStore.failure.spec.ts b/frontend/taskdeck-web/src/tests/store/localeStore.failure.spec.ts
index d7978e39c..ffa091956 100644
--- a/frontend/taskdeck-web/src/tests/store/localeStore.failure.spec.ts
+++ b/frontend/taskdeck-web/src/tests/store/localeStore.failure.spec.ts
@@ -2,13 +2,13 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
/**
- * Catalog-load failure revert (#1858 review round).
+ * Catalog-load failure and atomic locale commit (#2003).
*
- * When a lazy locale chunk fails to load, the flip-first switch must not leave
- * the app claiming Italian while rendering English: the runtime locale,
- * ``, and the in-memory store value all revert to English. The
- * persisted preference is kept on purpose so a transient failure self-heals on
- * the next boot — that asymmetry is asserted here too.
+ * A lazy locale switch must not claim Italian while the Italian catalog is
+ * still loading or has failed: the previously committed runtime locale,
+ * ``, and store value remain aligned. The persisted preference is
+ * kept on purpose so a transient failure self-heals on the next boot — that
+ * asymmetry is asserted here too.
*/
vi.mock('../../i18n', async (importOriginal) => {
@@ -21,12 +21,24 @@ import { useLocaleStore } from '../../store/localeStore'
const STORAGE_KEY = 'td.locale.v1'
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, resolve, reject }
+}
+
describe('localeStore — catalog load failure', () => {
beforeEach(() => {
setActivePinia(createPinia())
window.localStorage.clear()
i18n.global.locale.value = 'en'
+ document.documentElement.setAttribute('lang', 'en')
vi.mocked(ensureLocaleMessages).mockClear()
+ vi.mocked(ensureLocaleMessages).mockResolvedValue(false)
})
afterEach(() => {
@@ -34,12 +46,16 @@ describe('localeStore — catalog load failure', () => {
document.documentElement.removeAttribute('lang')
})
- it('reverts runtime locale, , and store state — but keeps the persisted preference', async () => {
+ it('keeps the committed locale aligned while a switch fails, and reports the failed target', async () => {
const store = useLocaleStore()
const pending = store.setLocale('it')
- // Flip-first window: the switch is live while the (doomed) load runs.
- expect(i18n.global.locale.value).toBe('it')
+ // Atomic commit: the previous language remains live while the catalog is
+ // in flight, and the pending target is explicit state for the picker.
+ expect(store.locale).toBe('en')
+ expect(store.pendingLocale).toBe('it')
+ expect(i18n.global.locale.value).toBe('en')
+ expect(document.documentElement.getAttribute('lang')).toBe('en')
await pending
@@ -47,22 +63,92 @@ describe('localeStore — catalog load failure', () => {
expect(i18n.global.locale.value).toBe('en')
expect(document.documentElement.getAttribute('lang')).toBe('en')
expect(store.locale).toBe('en')
+ expect(store.pendingLocale).toBeNull()
+ expect(store.failedLocale).toBe('it')
// Kept: a transient failure retries from storage on the next boot.
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('it')
})
- it('does not fight a newer switch that happened while the load was failing', async () => {
+ it('restores a stored locale atomically during startup when its catalog fails', async () => {
+ window.localStorage.setItem(STORAGE_KEY, 'it')
+ setActivePinia(createPinia())
+
+ const store = useLocaleStore()
+ const pending = store.apply()
+
+ expect(store.locale).toBe('en')
+ expect(store.pendingLocale).toBe('it')
+ expect(i18n.global.locale.value).toBe('en')
+ expect(document.documentElement.getAttribute('lang')).toBe('en')
+
+ await pending
+
+ expect(store.locale).toBe('en')
+ expect(store.pendingLocale).toBeNull()
+ expect(store.failedLocale).toBe('it')
+ expect(i18n.global.locale.value).toBe('en')
+ expect(document.documentElement.getAttribute('lang')).toBe('en')
+ })
+
+ it('lets the latest request win when an obsolete catalog resolves first', async () => {
const store = useLocaleStore()
+ const italian = deferred()
+ const spanish = deferred()
+
+ vi.mocked(ensureLocaleMessages).mockImplementation((locale) =>
+ locale === 'it' ? italian.promise : spanish.promise,
+ )
const first = store.setLocale('it')
const second = store.setLocale('es')
- await Promise.all([first, second])
- // The failed 'it' load must not revert the meanwhile-selected 'es'... which
- // itself failed too, so the final state is the reverted default — but via
- // the 'es' revert, never a stale 'it' writer. Either way the invariant
- // holds: runtime and store agree, and they are a supported value.
- expect(store.locale).toBe(i18n.global.locale.value)
expect(store.locale).toBe('en')
+ expect(store.pendingLocale).toBe('es')
+ expect(i18n.global.locale.value).toBe('en')
+
+ italian.resolve(true)
+ await first
+
+ // The obsolete success must not commit Italian or clear Spanish's
+ // pending state.
+ expect(store.locale).toBe('en')
+ expect(store.pendingLocale).toBe('es')
+ expect(i18n.global.locale.value).toBe('en')
+
+ spanish.resolve(true)
+ await second
+
+ expect(store.locale).toBe('es')
+ expect(store.pendingLocale).toBeNull()
+ expect(store.failedLocale).toBeNull()
+ expect(i18n.global.locale.value).toBe('es')
+ expect(document.documentElement.getAttribute('lang')).toBe('es')
+ })
+
+ it('does not let an obsolete failure undo a newer successful switch', async () => {
+ const store = useLocaleStore()
+ const italian = deferred()
+ const spanish = deferred()
+
+ vi.mocked(ensureLocaleMessages).mockImplementation((locale) =>
+ locale === 'it' ? italian.promise : spanish.promise,
+ )
+
+ const first = store.setLocale('it')
+ const second = store.setLocale('es')
+
+ spanish.resolve(true)
+ await second
+ expect(store.locale).toBe('es')
+ expect(store.failedLocale).toBeNull()
+
+ italian.resolve(false)
+ await first
+
+ expect(store.locale).toBe('es')
+ expect(store.pendingLocale).toBeNull()
+ expect(store.failedLocale).toBeNull()
+ expect(i18n.global.locale.value).toBe('es')
+ expect(document.documentElement.getAttribute('lang')).toBe('es')
})
})
diff --git a/frontend/taskdeck-web/src/tests/views/AppearanceSettingsView.language.spec.ts b/frontend/taskdeck-web/src/tests/views/AppearanceSettingsView.language.spec.ts
index a88b316ac..1b4d18806 100644
--- a/frontend/taskdeck-web/src/tests/views/AppearanceSettingsView.language.spec.ts
+++ b/frontend/taskdeck-web/src/tests/views/AppearanceSettingsView.language.spec.ts
@@ -1,5 +1,5 @@
import { beforeEach, afterEach, describe, expect, it } from 'vitest'
-import { mount } from '@vue/test-utils'
+import { flushPromises, mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import AppearanceSettingsView from '../../views/AppearanceSettingsView.vue'
import { useLocaleStore } from '../../store/localeStore'
@@ -66,11 +66,41 @@ describe('AppearanceSettingsView — language', () => {
expect(useLocaleStore().locale).toBe('en')
})
+ it('shows a pending status without moving the committed selection', () => {
+ const store = useLocaleStore()
+ store.pendingLocale = 'it'
+
+ const wrapper = mount(AppearanceSettingsView)
+ const group = wrapper.find('[data-testid="appearance-language"] [role="group"]')
+ const status = wrapper.find('[data-testid="appearance-language-status"]')
+
+ expect(group.attributes('aria-busy')).toBe('true')
+ expect(localeButton(wrapper, 'en').attributes('aria-pressed')).toBe('true')
+ expect(localeButton(wrapper, 'it').attributes('aria-pressed')).toBe('false')
+ expect(status.attributes('role')).toBe('status')
+ expect(status.text()).toBe('Loading Italiano…')
+ expect(wrapper.findAll('[data-locale]:disabled')).toHaveLength(0)
+ })
+
+ it('names a failed target and the language that remains active', () => {
+ const store = useLocaleStore()
+ store.failedLocale = 'it'
+
+ const wrapper = mount(AppearanceSettingsView)
+ const status = wrapper.find('[data-testid="appearance-language-status"]')
+
+ expect(status.attributes('role')).toBe('alert')
+ expect(status.text()).toBe("Couldn’t load Italiano. English remains active. Try again.")
+ expect(localeButton(wrapper, 'en').attributes('aria-pressed')).toBe('true')
+ expect(localeButton(wrapper, 'it').attributes('aria-pressed')).toBe('false')
+ })
+
it('selecting a language persists it through the preferences mechanism', async () => {
const wrapper = mount(AppearanceSettingsView)
const store = useLocaleStore()
await localeButton(wrapper, 'it').trigger('click')
+ await flushPromises()
expect(store.locale).toBe('it')
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('it')
@@ -85,6 +115,7 @@ describe('AppearanceSettingsView — language', () => {
expect(wrapper.text()).toContain('Language')
await localeButton(wrapper, 'it').trigger('click')
+ await flushPromises()
// The page it lives on is itself re-rendered in Italian.
expect(wrapper.text()).toContain('Aspetto')
@@ -94,6 +125,7 @@ describe('AppearanceSettingsView — language', () => {
expect(wrapper.find('[data-mode="paper"]').text()).toBe('Paper (chiaro)')
await localeButton(wrapper, 'es').trigger('click')
+ await flushPromises()
expect(wrapper.text()).toContain('Apariencia')
expect(wrapper.text()).toContain('Idioma')
})
@@ -102,18 +134,20 @@ describe('AppearanceSettingsView — language', () => {
const wrapper = mount(AppearanceSettingsView)
await localeButton(wrapper, 'es').trigger('click')
+ await flushPromises()
expect(document.documentElement.getAttribute('lang')).toBe('es')
await localeButton(wrapper, 'en').trigger('click')
+ await flushPromises()
expect(document.documentElement.getAttribute('lang')).toBe('en')
})
- it('restores a persisted language on the next visit', () => {
+ it('restores a persisted language on the next visit', async () => {
window.localStorage.setItem(STORAGE_KEY, 'es')
setActivePinia(createPinia())
const store = useLocaleStore()
- store.apply()
+ await store.apply()
expect(store.locale).toBe('es')
expect(mount(AppearanceSettingsView).text()).toContain('Apariencia')
@@ -123,7 +157,9 @@ describe('AppearanceSettingsView — language', () => {
window.localStorage.setItem(STORAGE_KEY, 'klingon')
setActivePinia(createPinia())
- expect(useLocaleStore().locale).toBe('en')
+ const store = useLocaleStore()
+ expect(store.preferredLocale).toBe('en')
+ expect(store.locale).toBe('en')
})
it('rejects an unsupported locale passed to setLocale', () => {
diff --git a/frontend/taskdeck-web/src/views/AppearanceSettingsView.vue b/frontend/taskdeck-web/src/views/AppearanceSettingsView.vue
index 8ee6d47f1..0c9b1a7c9 100644
--- a/frontend/taskdeck-web/src/views/AppearanceSettingsView.vue
+++ b/frontend/taskdeck-web/src/views/AppearanceSettingsView.vue
@@ -83,8 +83,31 @@ const languageOptions = computed(() =>
const activeLocale = computed(() => localeStore.locale)
function selectLocale(locale: SupportedLocale) {
- localeStore.setLocale(locale)
+ void localeStore.setLocale(locale)
}
+
+const localeStatus = computed(() => {
+ const pendingLocale = localeStore.pendingLocale
+ if (pendingLocale) {
+ return {
+ kind: 'pending' as const,
+ message: t('settings.language.loading', { locale: LOCALE_LABELS[pendingLocale] }),
+ }
+ }
+
+ const failedLocale = localeStore.failedLocale
+ if (failedLocale) {
+ return {
+ kind: 'error' as const,
+ message: t('settings.language.loadFailed', {
+ locale: LOCALE_LABELS[failedLocale],
+ activeLocale: LOCALE_LABELS[activeLocale.value],
+ }),
+ }
+ }
+
+ return null
+})
@@ -127,9 +150,9 @@ function selectLocale(locale: SupportedLocale) {