A visitor on a Spanish online store finds a checkout button that does nothing. She clicks the little bug icon in the corner and gets "What's on your mind?" and a button that says "Send". She can work it out. But she writes three words instead of three sentences, because describing a problem in a second language is effort, and nobody owes you that effort.
Until this week, that was every non-English site running BugDump. The widget spoke English, and translating it meant passing all 36 strings yourself. Most people didn't. It now ships in English, Russian, Spanish, French and Portuguese, and picks one on its own. The picking turned out to be the interesting part.
Update, October 3: German followed in SDK 0.0.42 as the sixth language. It needed shorter words too: the attach button says "Datei" (file) and the voice-note badge "Audio". The changelog entry has the details.

Three places a widget can learn a language from
An embedded widget runs on someone else's page. It can read three signals, and they don't always agree.
The page's lang attribute. <html lang="es"> is the site saying what language its content is in. Screen readers use it to choose pronunciation, and browsers look at it when they offer to translate a page. It describes the page, not the person reading it.
The browser's language list. navigator.languages is the visitor's preferences in order, something like ["pt-BR", "pt", "en"]. navigator.language is only the first entry. It describes the person, not the page.
What the site tells the widget. An explicit setting in the embed code. The most reliable of the three, and the one almost nobody fills in.
There is also the Accept-Language header, which a server sees on every request. A widget loaded as a script from a CDN never gets to read it, so it is out.
Why we start with the page
Support chat widgets often follow the browser, and for a conversation with a person that makes sense: you want to talk in your own language. A bug report widget is a different kind of thing. It sits inside someone else's interface, next to their buttons and their copy, and it is about their product. A French panel on an English checkout page looks like a bug in its own right.
So the order we chose is:
<html lang>, if it names a language the widget ships in.Otherwise, the first of the visitor's browser languages that it ships in.
Otherwise, English.
The page goes first so that an English site stays English, even for a visitor whose browser is set to Russian. The browser comes second so that a page with no lang attribute at all still gets something sensible.
The obvious hole is a page with the wrong lang. Plenty of starter templates ship <html lang="en"> and nobody ever touches it, so a site written entirely in Portuguese can still claim to be English. We decided the fix for that is the attribute, not a cleverer guess. A wrong lang already hurts that site's screen reader users. A widget that quietly works around it hides the problem instead of surfacing it.
Matching tags without being clever
Language tags come in many shapes: fr, fr-CA, FR, or pt_BR from a backend that has never heard of BCP 47. We ship one set of strings per language, so matching looks only at the part before the first - or _, lowercased:
function findLocale(tag) {
const base = tag.toLowerCase().split(/[-_]/)[0];
return SHIPPED_LOCALES.find((locale) => locale === base);
}
That means a visitor in Portugal gets Brazilian Portuguese. It isn't perfect, and we would rather say so than pretend.
The list lookup is deliberate. base in LOCALES reads almost the same, right up until a tag like constructor arrives and matches a property every JavaScript object inherits. Nobody sets lang="constructor" on purpose, but from a widget's point of view a lang attribute is input from a stranger.
Things that broke on the way
A test that depended on whose laptop ran it. Recent versions of Node have a global navigator, and its languages follows the machine's locale. Our configuration tests run without a DOM, so the first draft would have resolved "auto" to Russian on a machine set to Russian and to English in CI. Detection now returns nothing unless a document exists as well, which is what a non-browser environment should look like anyway.
Russian is long. "Прикрепить" (attach) has ten letters where English has six, and it pushed the third button in the action bar onto a second row. The fix wasn't CSS, it was a shorter word: the Russian button now says "Файл" (file) and lets the paperclip icon do the rest. The voice-note badge on attachments had the same problem. It is a 72-pixel tile, and "Nota de voz" ends in an ellipsis there, so Spanish and French now say "Audio" and Portuguese "Áudio".
You only find these by looking. We rendered the panel in every language before shipping, and every language except English needed at least one shorter word.
Strings glued together in code. "Uploading 1/3… 40%" was built with the word baked in, and so were "Microphone 2" and "Powered by Bugdump". Instead of inventing a template syntax, the translatable part became just the word, and the code adds the numbers and the name after it. That works because every language we ship puts the word first. Japanese wouldn't, and the day we add it, those three strings become templates.
Screen readers. The widget renders in a shadow root on the host page, so it inherits the page's lang. A French panel on an English page would be read aloud with English pronunciation. The widget now sets lang on its own element.
The site still decides
Automatic detection is a default, not a rule. A site can name the language in its embed code:
<script
src="https://bugdump.com/sdk/latest.js"
async
data-api-key="your-api-key"
data-locale="fr"
></script>
translations still replaces any string on top of whichever language was picked, so renaming "Send feedback" to "Report a bug" works the same in French as in English. For a language we don't ship, locale: 'nl' plus your own Dutch strings works too, and anything you leave out falls back to English.
On the BugDump Widget page, the same choice is a dropdown, and the live preview switches language as you pick:

What it cost
All five languages are bundled into the script. The four extra ones added about 2.5 KB gzipped, and the widget now weighs about 32 KB. We thought about loading them on demand, but that means one more request and a panel that can only render after it comes back. A loading state to save two and a half kilobytes is a bad trade.
What we didn't build: changing the language of a widget that is already on screen, and right-to-left layouts. None of the languages we ship is written right to left, and a single-page app that switches language can tear the widget down and start it again until someone needs more.
If you're building a widget yourself
Read
<html lang>beforenavigator.languages. Your widget is part of the page.Match on the base language, and declare the language you actually used with a
langattribute of your own.Let the site override everything, and make the override one attribute on the script tag.
Render every string in every language before you ship. Translations are longer than you expect.
BugDump's widget does all of this from SDK 0.0.41, and script tag users already have it. Translating and rebranding the widget covers every option, and the changelog entry has the short version.
BugDump adds a bug report button to your site with one script tag, and every report arrives with console logs, network requests, session replay, and environment data attached. The widget speaks English, Russian, Spanish, French, Portuguese and German.
