Parametron

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 any t() call resolves real strings; call once per app, e.g. in a test's beforeAll:

    import { initI18n } from "../../i18n/index.js"
    
    beforeAll(() => initI18n("en"))
  • setLocale(locale: Locale): Promise<void> — switches the active locale; every t(listener, key) call subscribed through localeState re-renders.
  • getLocale(): Locale — synchronous, non-reactive read.
  • currentLocale(listener: Listener): Locale — reactive read, sugar for localeState.get(listener).
  • localeState — the raw reactive State<Locale> (a Domphy toState), for code that wants to compose it with other reactive reads directly instead of going through currentLocale.
  • detectLocale(): Locale — wraps the underlying detectLocale({ pathSegment: true }), i.e. it only ever looks at the first URL path segment (/vi/...), never localStorage. A host that wants storage-based detection reads getLocale/persists it itself and calls setLocale on 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  // reactive

The 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 TranslateFnkey: 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 TranslateFn

Panel 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's TranslateFn section covers.
  • Model authoring reference — the engine side has no i18n concept of its own; labels on ModelJSON nodes are plain strings, not translation keys.
📖 4 min read