Multi-Language Support
A locale-aware string resolution system enabling Refrakt sites to render UI text, labels, accessibility strings, and structural headings in any language.
A locale-aware string resolution system enabling Refrakt sites to render UI text, labels, accessibility strings, and structural headings in any language.
v2.0 (2026-07) — reconciled against the current codebase. The intent and design principles are unchanged, but several concrete references were stale and the string inventory had drifted:
RunePackage → Plugin: the package registration type was renamed. mergePackages() → mergePlugins(), loadRunePackage() → loadPlugin(). The proposed translations bundle now hangs off Plugin / PluginThemeConfig.metaFields / blocks model (MetaField.label), not only StructureEntry.label. Both must be localized.buildStructureElement() has no config access today — a real, previously-unstated implementation cost."Travelers", "Duration", "Per person") were removed; the plan pipeline KIND_LABELS/TYPE_LABELS constants no longer exist; behaviors split into behaviors/ + elements/ and gained new string-bearing files; the business, media, and design plugins were never inventoried.canonicalSlug / i18nAliases localization shape this spec called for. Zone 7 is reframed accordingly.resolveString() helper is renamed resolveLocaleString() to avoid colliding with the unrelated resolveString() data-pipeline variable interpolator in packages/runes/src/data-pipeline.ts.v2.1 (2026-07) — resolved all six open questions plus two scope decisions (first-party translations, packaging). See the Decisions section; the design below reflects the chosen answers (auto-derived keys + extract tooling, Intl.PluralRules, JSON-per-locale, inline behavior delivery, per-key fallback, LocaleContext threading).
v2.2 (2026-07) — added the Forward Compatibility — Multi-Locale Sites section. Multi-locale (route-based locale trees, switcher, auto-detect) remains a Non-Goal and demand-driven, but this design is constrained so it can be added later without breaking changes.
All user-visible text generated by the transform pipeline, layout system, and client-side behaviors is currently hardcoded in English. Authors writing content in other languages see a mix of their content language and English UI chrome. This spec defines how to make that text localizable without breaking existing sites.
This spec covers the framework-generated text — labels, navigation chrome, accessibility strings, and structural headings injected by the identity transform, layouts, computed navigation, and behaviors. It does not cover content authoring language (that's the author's domain) or CLI/developer tooling strings (lower priority, English-only is acceptable).
An audit of the codebase identifies ~120+ distinct English strings across seven zones. Directory note: the plugin tree moved from the spec's original runes/<name>/ convention to plugins/<name>/src/; the behaviors package split into packages/behaviors/src/behaviors/ (DOM behaviors) and packages/behaviors/src/elements/ (custom elements).
Visible labels are emitted through two mechanisms:
StructureEntry.label (packages/transform/src/types.ts) — the legacy path, emits <span data-meta-label>.MetaField.label (packages/transform/src/types.ts, the SPEC-080 metaFields/blocks model) — emits <span data-meta-label> inside a chip (chip-row) or as the <dt> of a definition list.Both take a literal string and must resolve through the locale system. Current label strings by scope:
packages/runes/src/config.ts / packages/runes/src/tags/budget.ts): budget "Total", "Per day". (Note: the v1.0 references to core "Travelers:", "Duration:", "Per person" are removed from the codebase — do not re-inventory them.)plugins/learning/src/): "Est. time", "Difficulty", "Prep", "Cook", "Serves"plugins/docs/src/): "Since", "Deprecated", "Source", "Version", "Released in", "PR", "Tags"plugins/storytelling/src/): "Role", "Status", "Type", "Scale", "Category", "Alignment", "Size", "Structure"plugins/places/src/): "Date", "Location", "Register", "Ends"plugins/plan/src/): "ID", "Status", "Priority", "Complexity", "Assignee", "Milestone", "Created", "Modified", "Severity", "Target", "Supersedes", "Source", plus achievedLabel values ("Fixed", "Accepted", "Complete", "Done")plugins/marketing/src/tags/comparison.ts): "Recommended", plus "Supported" / "Not supported" / "Not applicable" aria-labelsplugins/business/src/): mostly author-supplied labels (emitAttributes: { label: '$label' }) — low fixed-string surface, but audit cast.ts / organization.ts for fixed headings.Trailing colons are now added by CSS, so label values themselves omit the :.
Strings created programmatically (e.g. via makeTag()), significantly reduced since v1.0:
packages/runes/src/tags/budget.ts): "Total", "Per day".KIND_LABELS / TYPE_LABELS constants no longer exist. "Blocked by" / "Blocks" are now section-name constants (plugins/plan/src/scanner-core.ts) and section keys in the plan tag files (tags/work.ts, tags/bug.ts, with "Related" as an alias). The pipeline render surface moved to a section/render model (plugins/plan/src/render.ts, commands/render-pipeline.ts) and needs a fresh audit for any remaining literals ("Relationships", "Progress", "criteria").packages/transform/src/layouts.ts — menu/search/navigation elements (intact):
"Open menu", "Close menu", "Search", "Toggle navigation", "Navigation menu", "Page navigation", "Plan navigation", plus newer "Toggle color theme", "Jump to section""Search", "Menu", "Plan"packages/transform/src/computed.ts — injected navigation text (intact):
buildToc() → "On this page" (ToC heading)buildPrevNext() → "Previous" / "Next"buildVersionSwitcher() → "Version"Client-side JavaScript, now split across packages/behaviors/src/behaviors/ and packages/behaviors/src/elements/:
"Copy code", "Copied""Image lightbox", "Close lightbox", "Previous image", "Next image", "View image {n}""Preview", "View source", "Auto", "Light", "Dark", "System preference", "Light mode", "Dark mode", "Markdoc", "Rune", "HTML""Continue", "Start over""Search documentation...", "No results found.", "Search is not available.", "to navigate", "to select", "Esc""Filter rows...", "Prev", "Next""Submitting...", "Form submitted successfully.", "Something went wrong. Please try again.", "Select an option""Comparison slider", "Comparison toggle", "Panel {n}""Play", "Pause", "Seek""Sandboxed user content""More info"New behavior files not in v1.0 (must be inventoried): behaviors/carousel.ts ("Previous", "Next"), behaviors/mobile-menu.ts ("Open menu", "Close menu"), behaviors/section-nav.ts / scrollspy.ts ("Page sections"). Additional element behaviors (elements/chart.ts, elements/diagram.ts, elements/nav.ts, elements/context.ts) should be scanned for axis/legend/loading strings.
Rune attribute values that double as visible display text:
packages/runes/src/tags/hint.ts: note, warning, caution, check): capitalized and displayed as the hint title. {% hint type="warning" %} renders "Warning" in any locale.packages/runes/src/tags/details.ts): "Details"packages/runes/src/tags/embed.ts): "Embedded content"plugins/design/src/tags/typography.ts): weight names ("Thin" … "Black"), pangram sample text, section titles.plugins/design/src/tags/palette.ts, new since v1.0): contrast readout "W: … · B: …", accessibility badges "AAA" / "AA" (✓/✗)."Constructor", "Properties", "Methods", "Static Properties", "Static Methods", "Accessors", "Index Signatures", "Class Methods") — now duplicated across plugins/docs/src/extract/typescript.ts and plugins/docs/src/extract/python.ts; both must be localized.(Removed since v1.0: diff "Before"/"After" are no longer JS string literals — packages/runes/src/tags/diff.ts emits lowercase data-name="before"/data-name="after" and the visible labels are CSS-generated. Diff localization is therefore a CSS/content: concern, out of scope for the string table.)
The knownSections feature is now implemented (WORK-024, done; delivered via SPEC-037, with SPEC-003 accepted). Today:
KnownSectionDefinition (packages/types/src/content-model.ts) declares canonical section names with an alias?: string[] list and an optional per-section model.packages/runes/src/lib/resolver.ts) matches heading text case-insensitively against the canonical name + aliases, attaching $canonicalName to matched sections.buildSections() (plugins/plan/src/util.ts) sets data-known-section={canonicalName} on the matched heading.What was NOT built — and remains the localization concern this spec owns: there is no canonicalSlug and no i18nAliases. slugify() in plugins/plan/src/util.ts still derives the section wrapper's data-name from raw heading text, so a non-English heading (## Akzeptanzkriterien) still produces a different data-name slug than ## Acceptance Criteria, breaking CSS selectors keyed on it. The data-known-section attribute provides a stable hook on the heading, but not a stable slug on the <section> wrapper.
The remaining work is therefore a scoped follow-up, no longer blocked on an unbuilt framework:
data-name from the matched canonical key (a stable canonicalSlug) instead of heading text, falling back to slugify($heading) for unrecognised sections.i18nAliases or resolved from config.strings) so authors can write headings in the site locale and still hit the canonical section.lang Attribute (cross-adapter)The document lang attribute must reflect the configured locale. This is no longer a single-file change: with the framework adapters (SPEC-030, SPEC-058), each adapter's page shell (html, astro, nuxt, next, eleventy, sveltekit) sets lang. All must read config.locale ?? 'en'.
A translated value is either a plain string or — for the small set of count-bearing strings (Decision D2) — a plural category map:
/** CLDR plural categories, as returned by Intl.PluralRules. */ type PluralMap = Partial<Record<Intl.LDMLPluralRule, string>>; // 'zero'|'one'|'two'|'few'|'many'|'other' /** A dictionary value: a literal string, or a per-category map for plurals. */ type LocalizedValue = string | PluralMap;
A locale section on ThemeConfig (packages/transform/src/types.ts — neither field exists today; both are net-new):
interface ThemeConfig { // ... existing fields: prefix, tokenPrefix, icons, runes, tints?, backgrounds?, frames? ... /** Locale identifier (BCP 47). Defaults to 'en'. */ locale?: string; /** Translation strings keyed by dotted path. * Keys follow the pattern: {scope}.{identifier} * Scope is 'core', 'layout', 'behavior', or a plugin name. * Missing keys fall back to the English default baked into the config. */ strings?: Record<string, LocalizedValue>; }
Example for a German site:
{ locale: 'de', strings: { // Zone 1 — structure / meta-field labels 'core.budget.total': 'Gesamt', 'core.budget.perDay': 'Pro Tag', // Zone 3 — layout chrome 'layout.openMenu': 'Menü öffnen', 'layout.closeMenu': 'Menü schließen', 'layout.search': 'Suche', 'layout.menu': 'Menü', 'layout.navigationMenu': 'Navigationsmenü', // Zone 4 — computed transforms 'core.toc.title': 'Auf dieser Seite', 'core.prevNext.previous': 'Zurück', 'core.prevNext.next': 'Weiter', 'core.versionSwitcher.label': 'Version', // Zone 5 — behaviors 'behavior.copy.copy': 'Code kopieren', 'behavior.copy.copied': 'Kopiert', 'behavior.gallery.previous': 'Vorheriges', 'behavior.gallery.next': 'Nächstes', 'behavior.search.placeholder': 'Dokumentation durchsuchen...', 'behavior.search.noResults': 'Keine Ergebnisse gefunden.', // Zone 6 — enum display values 'core.hint.note': 'Hinweis', 'core.hint.warning': 'Warnung', 'core.hint.caution': 'Achtung', 'core.hint.check': 'Erledigt', // A plural value (Decision D2) 'plan.progress.criteria': { one: '{n} Kriterium', other: '{n} Kriterien' }, // Plugin translations 'learning.howto.estimatedTime': 'Geschätzte Zeit:', 'learning.recipe.prep': 'Vorbereitung:', 'learning.recipe.cook': 'Kochen:', 'learning.recipe.serves': 'Portionen:', }, }
A resolveLocaleString(ctx, key, fallback) utility available to the engine, computed transforms, and layout builders, plus a plural-aware sibling. ctx is a narrow LocaleContext slice (Decision D6), not the full ThemeConfig:
interface LocaleContext { locale: string; // BCP 47, defaults to 'en' strings: Record<string, LocalizedValue>; // merged, locale-selected dictionary } function resolveLocaleString(ctx: LocaleContext, key: string, fallback: string): string { const v = ctx.strings[key]; return typeof v === 'string' ? v : fallback; } function resolvePluralString(ctx: LocaleContext, key: string, count: number, fallback: string): string { const v = ctx.strings[key]; if (v && typeof v === 'object') { const cat = new Intl.PluralRules(ctx.locale).select(count); return (v[cat] ?? v.other ?? fallback).replace('{n}', String(count)); } return typeof v === 'string' ? v.replace('{n}', String(count)) : fallback; }
Named
resolveLocaleString(notresolveString) to avoid colliding with the existing, unrelatedresolveString()inpackages/runes/src/data-pipeline.ts, which interpolates{{ }}template variables against data-pipeline sources.
Plurals and counts (Decision D2): Framework chrome prefers count-neutral phrasing ("Progress 3/10") so pluralization rarely arises. Where a natural phrasing genuinely inflects, the dictionary value is a PluralMap resolved via Intl.PluralRules for the active locale — English supplies { one, other }; Polish/Russian/Arabic supply the additional categories. No English n === 1 ternaries, and no ICU MessageFormat engine: the CLDR category model reached via Intl.PluralRules is identical to ICU's plural model without the parser or the client-bundle dependency. Revisit ICU only if framework chrome grows variable-laden sentences or a TMS workflow is adopted — and note the resolver dispatches on value shape, so a value could later become an ICU string behind the same key without a format-wide migration.
Zone 1 — Structure & MetaField labels: Both label paths must resolve through the locale table.
buildStructureElement() (packages/transform/src/engine.ts) currently receives only (entry, name, modifierValues, icons) — it has no access to the config. The MetaField renderers likewise emit field.label literally. Localization threads a narrow LocaleContext (Decision D6) into buildStructureElement() and the MetaField block renderers — matching the engine's existing convention of passing explicit slices (icons, modifierValues) rather than the whole config.{pluginScope}.{block}.{ref} — e.g., core.budget.total (Decision D1). Authors don't set keys manually; an optional i18nKey override on a field pins a stable key across refactors. Discoverability is handled by the refrakt i18n extract tooling (below), not by verbose per-label keys.Zone 2 — postTransform / programmatic text: postTransform hooks (and the plan render pipeline) receive the LocaleContext so they can call resolveLocaleString() in programmatic code.
Zone 3 — Layout chrome: Layout config builders (layouts.ts) receive the LocaleContext and use resolveLocaleString() for all text and aria-labels.
Zone 4 — Computed transforms: buildToc(), buildPrevNext(), buildVersionSwitcher() (computed.ts) already receive config-derived data; they use resolveLocaleString() with keys like core.toc.title.
Zone 6 — Enum display values: When the capitalize transform is applied to a metaText value, the engine first checks for a translation key {scope}.{block}.{value} (e.g., core.hint.warning). If found, the translation replaces both the capitalize transform and the raw value.
Behaviors run in the browser with no access to server-side config. Translations are delivered inline (no fetched endpoint):
data-i18n-* attributes: The identity transform emits data-i18n-{key}={translated-value} on rune root elements for behavior strings that attach to an element the transform already renders. Behaviors read these instead of hardcoded defaults. Prefer this path wherever a string has an anchor element.
<meta name="rf-locale"> tag + inline JSON block: ThemeShell (and each adapter's page shell) emits <meta name="rf-locale" content="de"> and a <script type="application/json" id="rf-strings"> block. The block carries only the strings behaviors create at runtime with no anchor element (e.g. "No results found." injected by search, the "Copied" state, dynamically-built gallery labels) — keeping it to ~15–20 strings, not all 45. Only the active locale is emitted.
// In each behavior: const label = el.dataset.i18nCopy ?? getGlobalString('behavior.copy.copy') ?? 'Copy code';
Fallback chain: element attribute → inline global block → hardcoded English default. Because the block is inline, strings are available synchronously at behavior init — no flash-of-English, no interactivity delay, and pages stay self-contained (works in SSR-only / no-JS mode). A single fetched /rf-strings.json was rejected: the payload is a couple KB, the caching win is marginal, and a fetch forces either blocked interactivity or a visible English→translated swap on the most visible chrome — plus per-adapter routing, base-path, and cache-invalidation cost. Revisit only if the behavior-string set grows large or soft-navigation duplication becomes measurable.
For a given key, resolution is first-match-wins, per key (a scalar lookup, not a deep merge of dictionaries):
ThemeConfig.strings[key]. Always wins; lets a site owner fix a shipped translation, adjust tone, or match brand terminology.translations for plugin-scoped keys, core's bundle for core.*/layout.*/behavior.*. Locale-tag fallback applies here: de-AT → de (progressive BCP-47 subtag stripping) before dropping through.fallback passed at the call site. Always present, so it is the universal floor.There is no per-plugin "default language": an untranslated key falls to English, never to some third language the site owner never asked for. Degradation is per key, not per language — a missing de key shows English inline while the rest of the page stays German, which is what makes shipping partial first-party bundles safe. The --check tooling below turns those per-key gaps into a measured coverage number.
Plugins ship translation bundles. There is no existing slot for this — a translations field is net-new on the plugin surface (packages/types/src/package.ts):
// packages/types/src/package.ts export interface Plugin { name: string; version: string; runes: Record<string, PluginRune>; extends?: Record<string, RuneExtension>; theme?: PluginThemeConfig; behaviors?: Record<string, unknown>; pipeline?: PluginPipelineHooks; fileRoots?: Record<string, string>; /** NEW: per-locale translation bundles, keyed by BCP 47 locale. * Authored as JSON files imported here (Decision D3). */ translations?: Record<string, Record<string, LocalizedValue>>; }
// plugins/learning/src/index.ts — JSON authored per locale, imported into the typed field import de from './i18n/de.json'; import fr from './i18n/fr.json'; export const learningPlugin: Plugin = { name: 'learning', // ... existing fields ... translations: { de, fr }, };
Merge path: mergePlugins() (packages/runes/src/plugins.ts) already aggregates plugin theme contributions (themeRunes, themeIcons, themeBackgrounds) into MergedPluginResult. Translation bundles follow the same pattern: thread a translations field through LoadedPlugin and MergedPluginResult, select the bundle for the configured locale (with the D5 tag fallback), and merge into the LocaleContext.strings. Site-level (ThemeConfig.strings) entries take precedence over plugin defaults per the D5 precedence chain.
knownSections has shipped, but without stable-slug support (see Zone 7). The locale integration is now a concrete follow-up on an existing type rather than a design against an unbuilt feature:
// packages/types/src/content-model.ts — proposed additions export interface KnownSectionDefinition { alias?: string[]; // exists today model?: StructuralContentModel; // exists today /** NEW: canonical slug for the section's data-name, independent of * source language. Falls back to slugify(canonicalName) if omitted. */ canonicalSlug?: string; /** NEW: per-locale heading aliases, matched in addition to `alias` * when config.locale is set. */ i18nAliases?: Record<string, string[]>; }
buildSections() (plugins/plan/src/util.ts) would derive the <section data-name> from canonicalSlug when a known section matched, and matchKnownSection() (packages/runes/src/lib/resolver.ts) would consult i18nAliases[config.locale] alongside the base alias list. This makes knownSections the enabler for content portability — the same CSS works whether the author writes ## Acceptance Criteria or ## Akzeptanzkriterien.
The locale field enables locale-aware formatting:
"5h 30m". With locale support, consult Intl.DurationFormat (or a polyfill) to produce "5 Std. 30 Min." in German.Intl.NumberFormat(config.locale) for locale-appropriate separators.BUDGET_CURRENCY_SYMBOLS; Intl.NumberFormat with style: 'currency' would replace the manual symbol lookup.refrakt i18n extractAuto-derived keys (Decision D1) are only usable if authors and translators can discover them, so the extract command is a first-class deliverable, not a convenience — it is the mitigation that makes auto-derivation acceptable:
metaFields (reusing the contracts machinery) and emits every derivable key with its English default as a JSON dictionary — the same key → value shape used for translation files (Decision D3), so extract → translate → commit de.json round-trips.--check mode (mirroring contracts --check) fails CI on drift: a new labelled field with no dictionary entry, or an orphaned key after a block rename. It also reports per-locale coverage ("de: 94%"), giving the first-party quality bar a measured number.refrakt.contracts precedent (CLAUDE.md prefers MCP over CLI where both exist, and an agent bootstrapping a translation wants structured key/default data more than parsed CLI text). Structured JSON output makes the wrapper thin; the detailed input schema is deferred to the work item.(This does not conflict with the "CLI/developer tooling i18n" non-goal — that non-goal is about translating refrakt's own CLI strings, not about tooling that supports the i18n system.)
Because framework-generated chrome is identical on every site, refrakt ships first-party translations rather than leaving every site owner to re-translate the same ~120 strings (Decision D7):
i18n/<locale>.json: core strings in packages/runes (alongside coreConfig), each plugin's strings in that plugin. Not one npm package per language — that would centralize distributed ownership (a per-language package would carry every scope's keys and re-release on any string change), and would split first-party (per-language packages) from community (in-package bundles) into two delivery models. The usual per-language-package driver — runtime client bundle-splitting — doesn't apply to a build-time generator that emits only the active locale. Fixed-mode Changesets versioning means a per-language package wouldn't even decouple releases. An optional aggregate convenience meta-package could re-export first-party bundles later if there's demand, but ownership stays in-package.| Priority | Zone / Item | Effort | Impact |
|---|---|---|---|
| P0 | ThemeConfig.locale + strings + LocaleContext + resolveLocaleString() / resolvePluralString() | Small | Foundation for everything else |
| P0 | Thread LocaleContext into buildStructureElement() + MetaField renderers | Medium | Prerequisite for Zone 1 — no config access today |
| P1 | Zone 1 (structure & meta-field labels) + auto-derived keys | Medium | Highest visibility — affects all runes with metadata |
| P1 | Zone 4 (computed transforms) | Small | 4 strings, highly visible on every page |
| P1 | Zone 3 (layout chrome) | Small | ~14 strings, visible site-wide |
| P1 | refrakt i18n extract (+ --check, MCP) | Medium | Discoverability that makes auto-derived keys usable |
| P2 | Zone 5 (behaviors) — inline delivery (data-i18n-* + JSON block) | Medium | ~45 strings, client-side delivery mechanism |
| P2 | Zone 6 (enum display values) | Small | Hint titles, typography, docs symbol groups |
| P2 | Zone 2 (postTransform / plan render) | Small | Re-audit needed — pipeline moved to render model |
| P2 | Plugin translations bundle + mergePlugins() wiring | Medium | Enables community-plugin localization |
| P3 | Zone 7 (knownSections canonicalSlug + i18nAliases) | Small | Framework now exists — scoped follow-up |
| P3 | Zone 8 (cross-adapter lang attribute) | Small | Correctness/a11y across all adapters |
| P3 | First-party translation bundles (per-locale JSON, in-package) | Ongoing | Batteries-included localization; demand-driven |
| P3 | Number/duration formatting | Small | Intl APIs handle most of the work |
refrakt inspect, refrakt plan, etc. (Distinct from refrakt i18n extract, which is tooling that supports the i18n system.)packages/ai/ prompts are English-only and used for content generation, not end-user display.content: (e.g. diff Before/After, trailing label colons) are a theming concern, not a string-table concern.Serving the same site in multiple languages (route-based locale trees, a language switcher, Accept-Language auto-detect) stays a Non-Goal and is demand-driven — we build it only if there's real demand. But this design is deliberately constrained so that feature can be added as an additive layer, without breaking changes. The constraints below are load-bearing; violating them would turn a future additive feature into a rewrite.
Constraints this spec must hold to keep the door open:
LocaleContext threaded through resolution (Decision D6) — not a singleton set once per process. This is the single most important constraint: a future route-based system may build multiple locale subtrees, potentially within one build, and any module-level "current locale" global would make that impossible without a rewrite. D6's threaded slice already satisfies this; do not shortcut it into a global.{scope}.{block}.{ref} never encodes a locale, so the same key space resolves against any locale's bundle. A multi-locale build reuses identical keys per subtree.<html lang> (Zone 8) and <meta name="rf-locale"> are per-page, and behavior strings are delivered inline per page (Decision D4) — so N locale subtrees each carry correct, self-contained locale state with no shared single-locale assumption. hreflang alternate-links are the clean additive extension point on top.canonicalSlug (Zone 7) is the cross-locale URL/anchor stabilizer. Language-independent section slugs mean anchors, deep links, and CSS selectors stay identical across locale trees (#acceptance-criteria in every language, not #akzeptanzkriterien). This is why Zone 7 matters beyond CSS stability — it's a prerequisite for portable URLs in a multi-locale site.What is explicitly deferred to that future feature (and sits on top of this foundation, not inside it): route layout (/en/…, /de/…), parallel/translated content trees, the switcher component, per-locale sitemaps, and any auto-detect redirect layer. None of these require changing the string-resolution model defined here.
Resolved from the v1.0 / v2.0 open questions:
{plugin}.{block}.{ref}), with an optional explicit i18nKey override on a field for stability across refactors. Discoverability is handled by the refrakt i18n extract tooling, not by verbose per-label keys. Rationale: ~68+ labels make explicit keys pure, drift-prone boilerplate; auto-derivation keeps keys correct by construction and covers both label paths uniformly; the extract command turns discoverability into a generated, exhaustive artifact.Intl.PluralRules + PluralMap dictionary values, plus a preference for count-neutral phrasing. No English n === 1 ternaries; no ICU MessageFormat engine. Rationale: hardcoded binary plurals are an i18n bug in most languages; full ICU is priced for variable-laden sentence-prose at scale, which refrakt's atomic-label / decomposed-metaField architecture largely designs away; Intl.PluralRules gives the identical CLDR model at zero dependency. Revisit only on variable-laden chrome or a TMS workflow — the resolver's shape-dispatch keeps that door open without a migration.string | PluralMap, imported into the typed translations field; inline TS allowed but JSON is canonical. Rationale: JSON is what translators and TMS tools consume; a typed JSON import keeps type safety; it round-trips with extract output.<script type="application/json" id="rf-strings"> per page + data-i18n-* attributes for element-attached strings; the global block carries only runtime-created strings. No fetched /rf-strings.json endpoint. Rationale: the payload is a couple KB; inline gives synchronous init (no flash-of-English), keeps pages self-contained, and avoids per-adapter routing / cache-invalidation. Revisit only if the string set grows large or soft-nav duplication becomes measurable.strings → owning package's shipped locale bundle (with de-AT→de tag fallback) → English floor. No per-plugin default language. Per-key degradation. Rationale: English is always present as the universal floor; a surprise third language is worse than English; per-key degradation makes partial bundles safe.LocaleContext { locale, strings } slice into buildStructureElement() and the MetaField renderers, not the full ThemeConfig. A bound t() closure is an acceptable alternative at implementation time. Rationale: matches the engine's existing convention of passing explicit slices (icons, modifierValues); plumbing cost is identical either way, so minimal coupling wins; narrow-first is the safe direction to widen later.--check. Rationale: chrome strings are universal, so translating them once is the framework's job; it's the difference between "i18n is possible" and "i18n is batteries-included."