After the Windows 11 Fluent pass, AlvaBars was still English-only. This post covers wiring Paraglide JS into a Tauri SPA so the UI, export labels, and sample data follow the OS — or an explicit language choice in Settings.

AlvaBars UI in English

English: base locale, gardening sample rows with USD prices, and the familiar W11 chrome.

AlvaBars UI in Spanish

Spanish (es): panels, data-source labels, grid headers, and sample product names all come from messages/es.json.

Language selector in Settings

Settings → Language: System (follow the OS) plus English, Deutsch, Français, and Español — language names stay as endonyms.


Summary

Problem. AlvaBars ships as a desktop app (Tauri 2 + SvelteKit SPA). Hard-coded English strings in +page.svelte and dialogs meant German, French, and Spanish users saw an English UI even when Windows or macOS was already set to their language. We also needed localized demo data and export-format labels without adopting URL-based locale routing (awkward for a file:// / static SPA shell). Searching / googling let us to Paraglide, the conversion process is detailed below.

Solution. Add inlang Paraglide with four message catalogs and a desktop-friendly locale strategy:

  1. Message files at messages/{en,de,fr,es}.json (inlang message format).
  2. Vite plugin compiles type-safe m.*() helpers into src/lib/paraglide/.
  3. Locale strategy ["localStorage", "preferredLanguage", "baseLocale"] — no path prefixes; prefs override, else OS language, else English.
  4. Settings control persists locale: "system" | Locale in app preferences; changing it updates Paraglide’s localStorage key and reloads when needed.
  5. Sample catalog & export presets call into the same message API (prices use $x.xx in English and x,xx EUR elsewhere).

Default remains System, so a clean install tracks the host OS without a settings visit. While this will work for the majority of users, it doesn’t hurt to give them choices. It’s also very convenient for testing.


Files we modified

Paths are relative to the AlvaBars app root (apps/alvabars).

Added

PathRole
messages/en.jsonBase locale catalog (~140 keys)
messages/de.jsonGerman
messages/fr.jsonFrench
messages/es.jsonSpanish
project.inlang/settings.jsoninlang project: locales + message path pattern
src/lib/i18n.tsPreference ↔ Paraglide bridge, locale option list, exportFormatLabel()
src/hooks.server.tsParaglide middleware / %paraglide.lang% injection
src/hooks.tsdeLocalizeUrl reroute helper
src/lib/paraglide/*Generated at build time (gitignored)

Wiring & UI

PathRole
package.json / lockfile@inlang/paraglide-js
vite.config.jsparaglideVitePlugin + desktop strategy
svelte.config.jspaths.relative: false (avoid locale-prefix asset 404s)
src/app.htmllang="%paraglide.lang%" / dir="%paraglide.dir%"
src/lib/preferences.tsPersist locale (default "system")
src/lib/components/PreferencesDialog.svelteLanguage select at bottom of Settings
src/lib/types.tsLocalized sample rows + EUR / $ price formatting
src/lib/components/CsvImportDialog.svelteDialog copy via m.*()
src/routes/+layout.svelteSync preference on launch; set document.documentElement.lang
src/routes/+page.svelteMain UI strings; apply locale after save / reset

Follow-up commit also localized remaining preview strings (clip tooltip, encode detail, row counts) and disabled Generate when the current preview cannot encode.


Structural changes (git-style diffs)

1. inlang project + Vite strategy

--- /dev/null
+++ b/project.inlang/settings.json
@@ -0,0 +1,17 @@
+{
+	"$schema": "https://inlang.com/schema/project-settings",
+	"modules": [
+		"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
+		"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
+	],
+	"plugin.inlang.messageFormat": {
+		"pathPattern": "./messages/{locale}.json"
+	},
+	"baseLocale": "en",
+	"locales": ["en", "de", "fr", "es"]
+}
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,3 +1,4 @@
+import { paraglideVitePlugin } from "@inlang/paraglide-js";
 import { defineConfig } from "vite";
 import { sveltekit } from "@sveltejs/kit/vite";
 import tailwindcss from "@tailwindcss/vite";
@@
-  plugins: [tailwindcss(), sveltekit()],
+  plugins: [
+    tailwindcss(),
+    sveltekit(),
+    paraglideVitePlugin({
+      project: "./project.inlang",
+      outdir: "./src/lib/paraglide",
+      emitTsDeclarations: true,
+      // Desktop / Tauri SPA: no URL locales. Prefs write localStorage; else OS language.
+      strategy: ["localStorage", "preferredLanguage", "baseLocale"],
+    }),
+  ],

Absolute Kit asset paths keep the static SPA from requesting locale-prefixed chunks:

--- a/svelte.config.js
+++ b/svelte.config.js
@@
     adapter: adapter({
       fallback: "index.html",
     }),
+    // Absolute asset paths avoid locale-prefixed 404s with SPA fallback + Paraglide.
+    paths: {
+      relative: false,
+    },

2. Preference to Paraglide bridge

system clears Paraglide’s override so preferredLanguage wins on the next load; an explicit locale writes the override and calls setLocale (which reloads).

--- /dev/null
+++ b/src/lib/i18n.ts
@@ -0,0 +1,56 @@
+import {
+	getLocale,
+	locales,
+	localStorageKey,
+	setLocale,
+	type Locale,
+} from "$lib/paraglide/runtime";
+import { m } from "$lib/paraglide/messages";
+
+export type AppLocalePreference = "system" | Locale;
+
+export const LOCALE_PREFERENCES = [
+	{ value: "system", label: "System" },
+	{ value: "en", label: "English" },
+	{ value: "de", label: "Deutsch" },
+	{ value: "fr", label: "Français" },
+	{ value: "es", label: "Español" },
+] as const;
+
+/** Sync Paraglide localStorage with a preference, then reload. */
+export function applyLocalePreference(pref: AppLocalePreference): void {
+	if (pref === "system") {
+		localStorage.removeItem(localStorageKey);
+		location.reload();
+		return;
+	}
+	if (getLocale() === pref) return;
+	setLocale(pref);
+}
+
+/** Apply preference on startup without forcing a reload when already matched. */
+export function syncLocaleFromPreference(pref: AppLocalePreference): void {
+	if (pref === "system") {
+		if (localStorage.getItem(localStorageKey)) {
+			localStorage.removeItem(localStorageKey);
+		}
+		return;
+	}
+	if (localStorage.getItem(localStorageKey) !== pref) {
+		localStorage.setItem(localStorageKey, pref);
+	}
+}
+
+/** Localized label for an export format preset (PNG / TIFF / SVG / PDF). */
+export function exportFormatLabel(id: ExportPresetId): string {
+	/* … m.export_png({ dpi, quality: m.quality_*() }) etc. … */
+}

Persist next to the other Settings fields:

--- a/src/lib/preferences.ts
+++ b/src/lib/preferences.ts
@@
 export type AppPreferences = {
 	loadSampleData: boolean;
+	/** UI language; `system` follows the OS. */
+	locale: AppLocalePreference;
 	c39: RatioCheckPrefs;
 	c25: RatioCheckPrefs;
 	itf14: Itf14Prefs;
 };
@@
 export function defaultPreferences(): AppPreferences {
 	return {
 		loadSampleData: true,
+		locale: "system",
 		c39: { ratio: 2.5, checkCalc: false, checkDisp: false },
 		/* … */
 	};
 }

3. Settings UI — language at the end

“System” is translated via m.locale_system(); language names stay endonyms so the list is recognizable regardless of the active UI locale.

--- a/src/lib/components/PreferencesDialog.svelte
+++ b/src/lib/components/PreferencesDialog.svelte
@@
+	import { LOCALE_PREFERENCES, type AppLocalePreference } from "$lib/i18n";
+	import { m } from "$lib/paraglide/messages";
@@
+	function localeOptionLabel(value: AppLocalePreference): string {
+		if (value === "system") return m.locale_system();
+		return LOCALE_PREFERENCES.find((o) => o.value === value)?.label ?? value;
+	}
@@
+				<div class="border-t border-border/80" role="separator"></div>
+
+				<section class="space-y-2">
+					<div class="space-y-1">
+						<Label class="text-xs">{m.locale_label()}</Label>
+						<Select.Root
+							type="single"
+							value={draft.locale}
+							onValueChange={(v) => {
+								if (v && LOCALE_PREFERENCES.some((o) => o.value === v)) {
+									draft.locale = v as AppLocalePreference;
+								}
+							}}
+						>
+							<Select.Trigger class="w-full">{localeLabel}</Select.Trigger>
+							<Select.Portal>
+								<Select.Content>
+									{#each LOCALE_PREFERENCES as opt}
+										<Select.Item value={opt.value} label={localeOptionLabel(opt.value)}>
+											{localeOptionLabel(opt.value)}
+										</Select.Item>
+									{/each}
+								</Select.Content>
+							</Select.Portal>
+						</Select.Root>
+					</div>
+				</section>

Apply after Ok / reset on the main page:

--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@
 	function applyPreferences(next: AppPreferences) {
+		const prevLocale = prefs.locale;
 		prefs = next;
 		savePreferences(next);
 		status = $platform === "windows" ? m.settings_saved() : m.preferences_saved();
+		if (next.locale !== prevLocale) applyLocalePreference(next.locale);
 	}

4. Message catalogs (shape)

Every UI string is a key. Interpolation uses {placeholders}:

--- /dev/null
+++ b/messages/en.json
@@ -0,0 +1,12 @@
+{
+	"$schema": "https://inlang.com/schema/inlang-message-format",
+	"locale_system": "System",
+	"locale_label": "Language",
+	"settings": "Settings",
+	"source_clipboard": "Import from clipboard",
+	"imported_rows": "Imported {count} rows",
+	"export_png": "PNG (RGB, {dpi} dpi, {quality})",
+	"sample_tomato_seed": "Tomato seed",
+	"cannot_encode": "Cannot encode \"{data}\" as {symbology}"
+	/* … full catalog ~140 keys … */
+}
--- /dev/null
+++ b/messages/es.json
@@ -0,0 +1,10 @@
+{
+	"locale_system": "Sistema",
+	"locale_label": "Idioma",
+	"settings": "Configuración",
+	"source_clipboard": "Importar desde el portapapeles",
+	"imported_rows": "Se importaron {count} filas",
+	"sample_tomato_seed": "Semillas de tomate"
+	/* … */
+}

Call sites replace literals with compiled helpers:

--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@
-	let status = $state("Idle");
+	let status = $state<string>(m.idle());
@@
-	status = `Imported ${parsed.length} rows`;
+	status = m.imported_rows({ count: parsed.length });

5. Localized sample data

--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@
+/** Format demo prices: `$3.49` in English, `3,49 EUR` elsewhere. */
+function formatSamplePrice(amount: number): string {
+	const fixed = amount.toFixed(2);
+	if (getLocale() === "en") {
+		return `$${fixed}`;
+	}
+	return `${fixed.replace(".", ",")} EUR`;
+}
@@
 export function sampleRows(): BarcodeRow[] {
-	/* hard-coded English gardening names */
+	const items = [
+		{ barcode: "2004387100009", above: m.sample_tomato_seed(), price: 3.49 },
+		/* … */
+	];
+	return items.map(({ barcode, above, price }) => ({
+		barcode,
+		above,
+		under: formatSamplePrice(price),
+		file: barcode,
+	}));
 }

Design notes

Why not URL locales? Paraglide’s default cookie/URL strategies fit websites. AlvaBars is a static Tauri SPA (ssr = false, adapter-static + index.html fallback). Locale lives in localStorage (prefs) or the OS (preferredLanguage), which matches how desktop apps usually behave.

Endonyms in the picker. The Language menu shows Deutsch / Français / Español even when the UI is English, so users can find their language without already knowing the translation of “German.”

Barcode text constraints. Supplemental / human-readable strings that go through the encoder are limited in character set. French and Spanish sample product names lean on ASCII where accents would otherwise break encoding; German keeps characters like Gießkanne where the pipeline allows them. UI chrome strings are free of that constraint.

Reload on change. Switching language reloads once so every m.*() call and the document lang attribute stay consistent — simpler than making the whole tree locale-reactive for a settings dialog that is rarely opened.


Takeaway

For a multi-locale Tauri + SvelteKit desktop app, Paraglide + JSON catalogs + a system preference gives OS-aware defaults and an explicit override without URL routing. Type-safe m.key() calls keep the main page and dialogs honest as the string inventory grows.

Stack: Tauri 2 · SvelteKit · Paraglide JS / inlang · AlvaBars V6 · (en | de | fr | es)