Internationalization
ERP screens are dense, and dense screens are where translation breaks: German runs ~35% longer than English, Arabic mirrors the whole layout, and a date written the American way is a support call in Europe. What the framework handles, what it deliberately leaves to you, and what to check before shipping a locale.
RTL: one attribute, because the CSS is logical
<!-- One attribute. The framework's CSS is written in LOGICAL properties
(inline-size, padding-inline, inset-inline-start), so layout mirrors
without a separate stylesheet. -->
<html lang="ar" dir="rtl"> The stylesheet uses logical properties throughout —
inline-size, padding-inline,
inset-inline-start, text-align: start — so
a right-to-left document mirrors without a mirrored stylesheet. Six
places need an explicit flip, because CSS gives no logical form for what
they use — and all six have one: the
select chevron (a background
image has no logical position), the
tree and
tree-table disclosure
chevrons (a glyph does not mirror — logical properties move boxes, not
the character inside them), the off-canvas
drawer slide direction, and
the slide-in-inline-start
animation (transform is physical; there is no logical
translate), and the
data table's row-state
stripe and data-tone cell bar (an inset
box-shadow's x-offset is physical). The count is gated: a
seventh unflipped case fails the build rather than shipping a stylesheet
that mirrors most of the way. Both
halves of this section were prose until 2026-08-17, and executing them
found two real defects — the animation slid in from the wrong edge
despite its logical name, and tree-table was missing the flip its
sibling tree already had. The sixth was found the same way in 2026-08-23,
from an owner question about the bar itself: the stripe rendered
inset 3px 0 0 byte-identically in both directions — so it
sat on the row's TRAILING edge in RTL — while its own forced-colors
fallback used logical border-inline-start and did flip. Two
channels for the same marker, disagreeing about which edge means start.
The gate could not see it, because a box-shadow is not a
physical box property; it now checks inset x-offsets too.
Your CSS must follow. A
single margin-left in app code re-breaks RTL. Numbers
stay left-to-right inside RTL text — that is correct, not a bug.
Numbers, currency and dates are your data
<!-- Format on the SERVER, in the user's locale, and send the string.
The framework aligns and spaces it; it never parses or reformats. -->
<td class="bo-data-table__col--numeric">1.234,56 €</td>
<!-- Client-side, if you must: -->
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.56)
new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date()) The framework never parses or reformats your values
— a deliberate boundary, because a UI that guesses at number formats
eventually corrupts an amount. What it does provide:
font-variant-numeric: tabular-nums so digits align in
columns, right alignment for numeric columns, and — for
editing — the money
and quantity fields that
derive decimal precision from the selected currency or unit, and
reformat only losslessly (they pad and trim zeros, never round).
Two rules that prevent the classic bugs:
format on the server where the locale is known, and keep the machine
value separate from the display string (that is exactly what
data-value does on a
combobox and what a
hidden input carries in a form).
Worked examples — Singapore and Thailand
Two locales that between them contain most of the traps an ERP hits in
the region. Every output below was produced by running Intl:
// Every string below was produced by running Intl, not written by hand,
// and a build check re-runs them so an ICU update cannot make this page lie.
new Intl.NumberFormat("en-SG", { style: "currency", currency: "SGD" }).format(1234567.5)
// "$1,234,567.50" <- a BARE dollar sign
new Intl.NumberFormat("en-SG", { style: "currency", currency: "USD" }).format(1234.5)
// "US$1,234.50" <- the foreign one gets disambiguated, the local one does not
new Intl.NumberFormat("en-SG", { style: "currency", currency: "SGD",
currencyDisplay: "code" }).format(1234.5)
// "SGD 1,234.50" <- what a multi-currency column wants
new Intl.NumberFormat("th-TH", { style: "currency", currency: "THB" }).format(1234567.5)
// "฿1,234,567.50"
new Intl.DateTimeFormat("th-TH", { dateStyle: "medium" }).format(new Date("2026-08-17"))
// "17 ส.ค. 2569" <- the BUDDHIST era: 2569, not 2026
new Intl.DateTimeFormat("th-TH-u-ca-gregory", { dateStyle: "medium" }).format(new Date("2026-08-17"))
// "17 ส.ค. 2026" <- what an audit trail needs Thai dates default to the Buddhist era. th-TH resolves to calendar: "buddhist", so a
2026 date renders as 2569 — a 543-year offset, in the
timestamps an audit trail is built on. Ask for
th-TH-u-ca-gregory wherever the year is a record rather
than a courtesy. (Thai digits are not a default: the numbering
system resolves to latn, and opting into
-u-nu-thai gives ฿๑,๒๓๔.๕๐, which no amount
column should use — it defeats tabular alignment and any parsing.)
The Singapore case is subtler: in
en-SG, SGD gets a bare $ while USD gets
US$. That is Intl being correct about the local
currency, and wrong for a multi-currency column, where
the reader cannot tell which dollar they are looking at. Use
currencyDisplay: "code" for any column that can hold more
than one currency — SGD 1,234.50 is unambiguous and, being
the same width for every code, keeps the column aligned.
Entry precision is not display precision
The framework's currencyDecimals() (used by the
money field to set an input's
step) follows ISO 4217 minor units — what a
currency formally subdivides into, and therefore what a user may type and
the system may store. Intl reports something else: the
CLDR display convention, which drops the minor unit for
currencies where it exists on paper but not in shops.
// Two DIFFERENT questions, two different answers:
currencyDecimals("IDR") // 2 — ISO 4217 minor units: what you may ENTER and store
new Intl.NumberFormat("en", { style: "currency", currency: "IDR" })
.resolvedOptions().maximumFractionDigits // 0 — CLDR display convention
// Using the display answer to set entry precision silently truncates cents. Both are right about their own question, and they disagree for exactly
seven codes at the time of writing — IQD,
IDR, HUF, COP, PKR,
MMK, LAK. Measured, not assumed: a build check
walks the framework's table against ICU and reports the divergence, so it
stays a documented list rather than a surprise.
The rule: take entry and
storage precision from ISO minor units (the framework already does), and
take the rendered string from Intl. Deriving step
from Intl is the bug this section exists to prevent — on an
IDR or HUF amount it silently truncates whatever the user typed after the
decimal point. And JPY really is zero-decimal in both
answers, so "always two" is wrong even before you reach the edge cases.
String expansion — the dense-screen killer
- Table headers wrap, they do not truncate. Verify at compact density in your longest locale — that is the combination that breaks first.
- Buttons size to content, so a long label grows the button rather than clipping. Fixed-width action columns are the anti-pattern.
- Don't build sentences from fragments. "Approve" + n + "invoices" cannot be translated into languages that inflect; pass the whole sentence with a placeholder.
- Badges and chips are the tightest spaces — prefer a short status vocabulary you control over free translation.
- Test with a pseudo-locale (every string ×1.4, accented) before the first real translation lands; it finds clipping without a translator. This framework does: a CI gate expands every visible string ~35% with accents, forces compact density, and fails the build if anything clips or the shell scrolls sideways — eight text-dense pages at desktop and phone width. The claims above are measured, not asserted.
Timezones in an audit trail
Audit timestamps are legal records. Store UTC, render in the
viewer's zone, and say which zone you rendered —
"2026-08-16 15:02 CET" is unambiguous; "15:02" is not. Use a real
<time datetime="…"> element so the machine-readable
instant travels with the display string, and keep a fixed format in
audit trails rather than relative time ("2 hours ago" is unusable in
a dispute).