Skip to content

Add in-app language selector with live (no-reload) switching#927

Merged
willeastcott merged 4 commits into
mainfrom
language-selector
Jun 29, 2026
Merged

Add in-app language selector with live (no-reload) switching#927
willeastcott merged 4 commits into
mainfrom
language-selector

Conversation

@willeastcott

@willeastcott willeastcott commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #923

What

Adds a Language selector to the editor's Settings panel (renamed from "View Options") and reworks localization so changing language applies live, with no page reload — the loaded scene, engine state, and unsaved work are all preserved.

Why

SuperSplat chose its UI language solely from the browser locale, with the undiscoverable ?lng= query parameter as the only override. Users whose locale doesn't match their preferred language (e.g. an English speaker on a Japanese-locale machine) had no obvious way to change it. This makes the choice explicit, discoverable, and persistent.

How

Cohesive interface. src/ui/localization.ts is refactored from a flat set of free functions into a Localization class exposed as a single i18n singleton, encapsulating the i18next instance and the language-change subscriber set:

Member Purpose
i18n.t(key, opts?) translate
i18n.bindText(el, key | builder) reactive .text (Label/Button)
i18n.bindOptions(select, builder) reactive SelectInput options
i18n.onChange(fn, owner?) run now + on every language change
i18n.setLanguage(code | null) switch live; null = Automatic
i18n.storedLanguage explicitly pinned code, or null on Automatic
i18n.locale / i18n.languages active code / supported list
i18n.init() startup

Reactivity. Bound UI re-translates on i18next's languageChanged event; binders auto-unsubscribe via the element destroy event (no leaks for transient dialogs). Tooltips and menu items take a resolver (string | () => string) so they reflect the current language with no listener. All ~220 call sites were migrated across labels, buttons, transformed text (.toUpperCase()/ellipsis), SelectInput option lists, menu items, tooltips, and the data panel's interpolated + raw-DOM strings. Runtime popup/progress messages are left as-is since they're rebuilt at call time.

Persistence. Detection order is querystring → localStorage → navigator → htmlTag, so ?lng= still wins for shareable links, an explicit choice beats the browser locale, and an untouched setting follows the browser. i18next's automatic localStorage caching is disabled (caches: []) so a stored value reliably means an explicit pick rather than "detected this time"; setLanguage manages the key itself — writing the chosen code on a pick, removing it on Automatic (which then reverts to browser detection). i18n.storedLanguage exposes the pinned code (or null) so the selector can default to Automatic until the user chooses.

Strings. Adds panel.view-options.language / .language.auto to all nine locale files, and updates the panel header + toolbar tooltip strings to "Settings". Translation key names (panel.view-options.*) are left unchanged as namespacing. Language names are shown in their native form and live in code (not the locale files), so a user who can't read the current UI language can still recognise their own.

Notes for reviewers

  • Default is "Automatic". The selector reads Automatic until the user pins a language; pinned choices persist across sessions, and selecting Automatic clears the choice and reverts to browser detection.
  • New persistent UI strings must be registered via i18n.bindText / bindOptions / onChange, or they won't update on switch — documented in the Localization class JSDoc. Plain i18n.t() is only correct for call-time strings.
  • The panel rename touches only the two user-facing strings (header + toolbar tooltip); the toolbar already uses a gear-style icon. (The panel header uses a separate glyph — left unchanged.)
  • Non-English translations for "Language" / "Automatic" / "Settings" are first-pass native renderings and may warrant a translation-workflow review.
  • No PCUI changes — the binding layer is kept local to SuperSplat (a generic reactive-text feature was considered but deferred to avoid a cross-repo change).

Verification

npm run lint, npx tsc --noEmit, and npm run build all pass clean. Suggested manual check: a fresh load shows Automatic; open Settings (right toolbar), switch language → entire UI updates instantly with the scene intact; reload → a pinned choice persists; selecting Automatic reverts to the browser locale; ?lng=fr still overrides.

🤖 Generated with Claude Code

SuperSplat previously chose its UI language solely from the browser
locale, with the undiscoverable ?lng= query param as the only override.
Users whose locale doesn't match their preferred language had no obvious
way to change it.

This adds a Language selector to the View Options panel and reworks
localization so switching applies live, with no page reload — the loaded
scene, engine state, and unsaved work are all preserved.

Localization is refactored from a flat set of free functions into a
cohesive `Localization` class exposed as a single `i18n` singleton
(src/ui/localization.ts), encapsulating the i18next instance and the
language-change subscriber set:

  i18n.t(key, opts?)                  translate
  i18n.bindText(el, key|builder)      reactive .text (Label/Button)
  i18n.bindOptions(select, builder)   reactive SelectInput options
  i18n.onChange(fn, owner?)           run now + on every language change
  i18n.setLanguage(code|null)         switch live; null = Automatic
  i18n.locale / i18n.languages        active code / supported list
  i18n.init()                         startup

Reactivity: bound UI re-translates on i18next's `languageChanged` event;
binders auto-unsubscribe via the element `destroy` event. Tooltips and
menu items take a resolver (string | () => string) so they reflect the
current language without a listener. All ~220 call sites were migrated
across labels, buttons, transformed text, SelectInput option lists, menu
items, tooltips, and the data panel's interpolated/raw-DOM strings.

Persistence: enables i18next's localStorage cache. Detection order is
querystring -> localStorage -> navigator -> htmlTag, so ?lng= still wins
for shareable links, an explicit choice beats the browser locale, and
behaviour is unchanged when nothing is stored. "Automatic" clears the
stored choice and reverts to the detected locale.

Adds panel.view-options.language / .language.auto strings to all nine
locale files (native language names live in code, not the locale files).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@willeastcott willeastcott requested a review from slimbuck June 29, 2026 14:46
@willeastcott willeastcott self-assigned this Jun 29, 2026
@willeastcott willeastcott added the enhancement New feature label Jun 29, 2026
The localization migration left `new Label({})` as an empty multi-line
object after its only `text` property was removed; collapse to `new Label()`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
willeastcott and others added 2 commits June 29, 2026 16:00
Previously the dropdown defaulted to the resolved active language (e.g.
"English"), so an untouched setting never read "Automatic" and the option
only appeared once selected — surprising for users who hadn't chosen.

Now the selector shows "Automatic" unless the user has explicitly pinned a
language. This required distinguishing an explicit choice from auto-detection:
i18next's localStorage cache wrote the detected language on every load, so its
presence couldn't signal intent. Disable that auto-cache (caches: []) and have
setLanguage manage the key itself — write it on an explicit pick, remove it on
"Automatic". A new i18n.storedLanguage getter exposes the pinned code (or null)
for the selector's default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With a Language preference now living in the panel, "View Options" no longer
describes its contents — Language is an app-level setting, not a view option.
"Settings" is an honest umbrella for both the view/render controls and the
language preference, and is where users instinctively look for things like
language.

Updates only the two user-facing strings (panel header and toolbar tooltip)
across all nine locales; the panel.view-options.* key names are left as-is
(namespacing) and the toolbar already uses a gear-style icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@willeastcott willeastcott merged commit 718abc1 into main Jun 29, 2026
2 checks passed
@willeastcott willeastcott deleted the language-selector branch June 29, 2026 15:07
dimitribarbot added a commit to dimitribarbot/supersplat that referenced this pull request Jul 2, 2026
Resolve conflicts by adopting upstream's reactive i18n localization (PR playcanvas#927,
which removed the free localize()/formatTooltipWithShortcut() helpers) across all
fork subsystems: converted ~80 localize() call sites to i18n.t(), using thunk form
() => i18n.t() at reactive menu/tooltip sites. Locale files merged (all fork keys
kept; upstream's renamed 'panel.view-options' -> Settings + language-selector keys
adopted). Kept fork's richer LCC import guard alongside upstream's i18n change.

Requires @playcanvas/splat-transform 2.7.1 (upstream dep bump, PR playcanvas#929) which now
exports WorkerQueue, used by upstream's inline worker-pool fix (PR playcanvas#930).

Verified: npm run lint (clean), npm run build (ok), npm run test (189/189 pass).

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

Labels

enhancement New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regional Settings != Language

2 participants