packages/ui/src/i18n/ is @parametron/ui's own string catalog: a single @domphy/i18n instance holding panel/chrome strings only — labels, placeholders, menu items, toolbar tooltips for BuilderPanel, CatalogPanel, and the rest of the reference UI. It is not a general i18n framework and it does not carry app-level or auth strings — those live with the app and with @parashape/dshouse-auth respectively, each in their own @domphy/i18n instance. A host app composes all of them side by side.
Import
import { t, initI18n, setLocale, getLocale, currentLocale, localeState, detectLocale, SUPPORTED, LOCALE_LABELS } from "@parametron/ui"
import type { Locale, I18nKey } from "@parametron/ui"Re-exported in full from the package root (ui/src/index.ts does export * from "./i18n/index.js") — @parametron/ui has no build step (main: src/index.ts) and no /i18n subpath, so everything above comes from the same @parametron/ui specifier.
One instance, one bundle
const i18n = createI18n<Locale, typeof en>({
globalKey: "__parametron_ui_i18n__",
namespace: "parametron",
locales: { en, vi: vi as typeof en },
defaultLocale: "en",
})createI18n (from @domphy/i18n) dedups on globalKey under globalThis — resources are initialized once per store. That means instances are not shared across packages: this call creates a store scoped to @parametron/ui alone, distinct from whatever instance an app or the auth package creates under its own globalKey. There is no cross-package lookup or merge — each owner carries its own catalog, and composition happens at the app layer (see Composing with a host app below).
Locale is "en" | "vi", matching the two JSON files under locales/. SUPPORTED (readonly Locale[]) and LOCALE_LABELS (Record<Locale, string>, { en: "English", vi: "Tiếng Việt" }) are exported for building a language switcher without hard-coding the locale list twice.
Reading and switching locale
All of these are the underlying @domphy/i18n instance's methods, re-exported by name:
initI18n(locale?: Locale): Promise<void>— must run before anyt()call resolves real strings; call once per app, e.g. in a test'sbeforeAll:import { initI18n } from "../../i18n/index.js" beforeAll(() => initI18n("en"))setLocale(locale: Locale): Promise<void>— switches the active locale; everyt(listener, key)call subscribed throughlocaleStatere-renders.getLocale(): Locale— synchronous, non-reactive read.currentLocale(listener: Listener): Locale— reactive read, sugar forlocaleState.get(listener).localeState— the raw reactiveState<Locale>(a DomphytoState), for code that wants to compose it with other reactive reads directly instead of going throughcurrentLocale.detectLocale(): Locale— wraps the underlyingdetectLocale({ pathSegment: true }), i.e. it only ever looks at the first URL path segment (/vi/...), neverlocalStorage. A host that wants storage-based detection readsgetLocale/persists it itself and callssetLocaleon boot.
t() and the loose TranslateFn cast
t has two call shapes, both delegating to the underlying instance:
t(key: string, options?: Record<string, unknown>): string // static, no reactivity
t(listener: Listener, key: string, options?: Record<string, unknown>): string // reactiveThe real @domphy/i18n type parameterizes t's key argument over a literal union derived from the resource shape, which is exactly what generated-types.ts' I18nKey provides. But packages/ui's own t is exported as a loose TranslateFn — key: string, not key: I18nKey — via an explicit cast:
// Loose key type on purpose: panel code builds keys dynamically
// (t(`menu.${method}`)), which a literal-union key type rejects.
type TranslateFn = {
(key: string, options?: Record<string, unknown>): string
(listener: Listener, key: string, options?: Record<string, unknown>): string
}
export const t = i18n.t as TranslateFnPanel code frequently builds a key from data it doesn't know at compile time (a node's method name, a visibility value used as a lookup into VISIBILITY_META), and TypeScript's literal-union key type rejects any non-literal string, template or not. The cast trades that compile-time exhaustiveness for the ability to call t with a computed key. I18nKey is still exported and still useful for call sites with a static key — the BuilderPanel/ParameterSection pattern is to type a lookup table's values as I18nKey and let t accept whatever the table produces:
import { type I18nKey, t } from "../../i18n/index.js"
const VISIBILITY_META: Record<ModelVisibility, { key: I18nKey; color: string }> = {
draft: { key: "visibility.draft", color: "neutral" },
private: { key: "visibility.private", color: "primary" },
public: { key: "visibility.public", color: "success" },
}
// reactive read, re-renders on setLocale()
option: t(l, VISIBILITY_META[key].key)
// static read with interpolation
window.confirm(t("admin.deleteConfirm", { title: placed.name }))Catalog structure
locales/en.json (mirrored key-for-key by locales/vi.json) is a flat set of namespaced objects — parameter, field, display, viewport, visibility, configurator, asset, menu, dialog, group, language, ui, toolbar, admin, sketchup. Namespace-to-panel mapping is loose by design (e.g. toolbar.* covers both the builder's save button and the SketchUp viewport toolbar) — grep the JSON for a namespace before adding a new key to it. i18next pluralization suffixes are used where a count is involved:
"entries_one": "{{count}} entry",
"entries_other": "{{count}} entries"generated-types.ts (I18nKey, a flat string-literal union of every namespace.key path) is auto-generated by packages/ui/scripts/gen-types.mjs from locales/en.json — treat it the same way as the parametric package's AUTOGEN doc blocks: edit the JSON locale files, then regenerate with pnpm --dir packages/ui i18n:types, never hand-edit the union.
Composing with a host app
@parametron/ui's instance only ever answers for its own keys. A host composing this reference UI with its own app strings initializes both:
// apps/web/src/i18n.ts (parashape repo) — sketch, not this repo's code
import { initI18n as initUiI18n } from "@parametron/ui"
import { initI18n as initAppI18n } from "./i18n/app"
export async function initLocales(locale: Locale) {
await Promise.all([initUiI18n(locale), initAppI18n(locale)])
}Language switching is a full navigation to /<locale>/* in that setup — there is no cross-instance setLocale broadcast, since detectLocale's pathSegment detection re-derives the locale from the new URL on load. A host that wants live in-place switching instead calls setLocale on every composed instance itself.
See also
- UI overview & chrome — where this instance sits among the other chrome pieces (router, app state, brand theme) and the panel-cluster folder layout.
- Builder panel and Catalog panel — the two heaviest consumers of
t(), including the dynamic-key pattern this page'sTranslateFnsection covers. - Model authoring reference — the engine side has no i18n concept of its own; labels on
ModelJSONnodes are plain strings, not translation keys.