feat(client): add nine interface languages and a searchable language picker

Traditional Chinese cannot reuse the `%two_letters_code%` filename because it would collide with
Simplified under `zh.json`, so `crowdin.yml` maps `zh-TW` to a `zh-Hant` catalog and locale
matching resolves by script subtag before falling back to the base language. Every other new
locale keeps its bare two-letter ID.

Twenty-five entries outgrew the old menus. The mobile submenu inherited `overflow-hidden` with no
max-height, so the last nine languages were unreachable on a phone, and the list was ordered by
ISO code while displaying endonyms, which reads as unsorted. Both header menus now render one
LanguagePicker that searches the native name, the English name and the locale code, groups the
browser's own languages as suggestions, and orders the rest with a collator on the string it
actually shows. Mobile opens a sheet instead of a nested flyout, since a flyout is the wrong
shape for touch regardless of height. Settings gains the Appearance language section its copy
has anticipated since the keys were added.

Tighten the Crowdin progress chart, which the longer language list had stretched to 810px tall.
This commit is contained in:
neon
2026-08-11 07:25:09 +00:00
parent 953c9e334b
commit 7dcc509a54
31 changed files with 573 additions and 88 deletions
+21 -13
View File
@@ -14,21 +14,21 @@ const REQUEST_TIMEOUT_MS = 30_000;
const MAX_RESPONSE_BYTES = 1_000_000;
const SOURCE_LANGUAGE_ID = "en";
const WIDTH = 540;
const PADDING_X = 24;
const WIDTH = 460;
const PADDING_X = 20;
const DIVIDER_Y = 51;
const ROW_BASELINE = 78;
const ROW_HEIGHT = 28;
const BAR_HEIGHT = 8;
const BAR_RADIUS = 4;
const BAR_BASELINE_OFFSET = 9;
const ROW_HEIGHT = 25;
const BAR_HEIGHT = 6;
const BAR_RADIUS = 3;
const BAR_BASELINE_OFFSET = 8;
const FLAG_WIDTH = 26;
const LABEL_CHAR_WIDTH = 7.6;
const LABEL_MAX_WIDTH = 180;
const LABEL_CHAR_WIDTH = 7.1;
const LABEL_MAX_WIDTH = 140;
const LABEL_GAP = 18;
const PERCENT_WIDTH = 42;
const PERCENT_WIDTH = 38;
const PERCENT_GAP = 14;
const LEGEND_SWATCH = 8;
@@ -179,9 +179,17 @@ function flagFromLocale(locale) {
return String.fromCodePoint(...[...region.toUpperCase()].map((letter) => letter.codePointAt(0) + 0x1f1a5));
}
// The label column is sized by the single widest name, so Crowdin's Chinese names
// would cost every other row roughly 60px of bar. The flags already carry the
// region, leaving the script marker as the only part the name has to supply.
const DISPLAY_NAME_OVERRIDES = new Map([
["zh-CN", "Chinese (S)"],
["zh-TW", "Chinese (T)"],
]);
function displayFor(languageId, apiLanguage) {
const flag = flagFromLocale(apiLanguage?.locale);
const name = apiLanguage?.name || languageId;
const name = DISPLAY_NAME_OVERRIDES.get(languageId) || apiLanguage?.name || languageId;
return {
name: flag ? name.split(",")[0].trim() : name,
@@ -320,7 +328,7 @@ export function generateSvg(statsData) {
const targetCount = languages.filter((language) => !language.isSource).length;
const summary =
targetCount > 0
? `Crowdin · ${targetCount} target ${targetCount === 1 ? "language" : "languages"} · ${averagePercent(languages)}% average`
? `Crowdin · ${targetCount} ${targetCount === 1 ? "language" : "languages"} · ${averagePercent(languages)}% average`
: "Crowdin · no translation data";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${layout.height}" viewBox="0 0 ${WIDTH} ${layout.height}" fill="none" role="img" aria-label="BookOrbit translation progress">
@@ -329,8 +337,8 @@ export function generateSvg(statsData) {
.card { fill: #0d1117; stroke: #30363d; stroke-width: 1px; rx: 12px; }
.title { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 600; fill: #f0f6fc; }
.subtitle { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 12px; font-weight: 400; fill: #8b949e; }
.lang-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 500; fill: #e6edf3; }
.pct-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13.5px; font-weight: 600; fill: #e6edf3; font-variant-numeric: tabular-nums; }
.lang-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 500; fill: #e6edf3; }
.pct-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 12.5px; font-weight: 600; fill: #e6edf3; font-variant-numeric: tabular-nums; }
.legend-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 11px; font-weight: 400; fill: #8b949e; }
.grid { stroke: #21262d; stroke-width: 1; }
.bar-bg { fill: #21262d; }
+17 -10
View File
@@ -95,6 +95,13 @@ test("extractLanguages derives flags from the locale and defaults missing progre
const [unknown] = extractLanguages({ progress: [{ data: { languageId: "eo" } }] });
assert.equal(unknown.name, "eo");
// Crowdin's own Chinese names are long enough to widen the label column for every row.
const [simplified] = extractLanguages({ progress: [language("zh-CN", "Chinese Simplified", "zh-CN", "99%")] });
assert.equal(simplified.name, "Chinese (S)");
const [traditional] = extractLanguages({ progress: [language("zh-TW", "Chinese Traditional", "zh-TW", "0%")] });
assert.equal(traditional.name, "Chinese (T)");
assert.equal(unknown.flag, "");
assert.equal(unknown.percent, 0);
});
@@ -107,7 +114,7 @@ test("generateSvg renders every target language with its percentage", () => {
assert.match(svg, /🇧🇷 {2}Portuguese</);
assert.match(svg, /🇸🇮 {2}Slovenian/);
assert.match(svg, />97%</);
assert.match(svg, /4 target languages · 96% average/);
assert.match(svg, /4 languages · 96% average/);
assert.match(svg, /English/);
});
@@ -120,20 +127,20 @@ test("generateSvg renders the source language like any other completed row but k
assert.equal(source.percent, 100);
assert.doesNotMatch(svg, /bar-source/);
// English sits at 100%, so it takes the completed tier and fills the whole bar.
assert.match(svg, new RegExp(`width="${layout.barWidth}" height="8" rx="4" class="bar-done"`));
assert.match(svg, new RegExp(`width="${layout.barWidth}" height="6" rx="3" class="bar-done"`));
assert.match(svg, /96% average/);
});
test("generateSvg renders at its natural size so label type is never scaled down", () => {
const { height } = computeLayout(extractLanguages(sampleStats));
assert.match(generateSvg(sampleStats), new RegExp(`<svg [^>]*width="540" height="${height}" viewBox="0 0 540 ${height}"`));
assert.match(generateSvg(sampleStats), new RegExp(`<svg [^>]*width="460" height="${height}" viewBox="0 0 460 ${height}"`));
});
test("generateSvg grows the canvas instead of overflowing when languages are added", () => {
const withExtra = generateSvg({ progress: [...sampleStats.progress, language("es", "Spanish", "es-ES", "40%", 4000, 19358)] });
assert.match(withExtra, /viewBox="0 0 540 278"/);
assert.match(withExtra, /viewBox="0 0 460 263"/);
assert.match(withExtra, /🇪🇸 {2}Spanish/);
assert.match(generateSvg(sampleStats), /viewBox="0 0 540 250"/);
assert.match(generateSvg(sampleStats), /viewBox="0 0 460 238"/);
});
test("computeLayout keeps horizontal rows at any language count", () => {
@@ -142,7 +149,7 @@ test("computeLayout keeps horizontal rows at any language count", () => {
const twenty = computeLayout(rows(20));
assert.equal(nine.count, 9);
assert.equal(twenty.height - nine.height, 11 * 28, "each extra language adds exactly one row");
assert.equal(twenty.height - nine.height, 11 * 25, "each extra language adds exactly one row");
});
test("generateSvg renders many languages as rows without rotating labels", () => {
@@ -156,7 +163,7 @@ test("generateSvg renders many languages as rows without rotating labels", () =>
const svg = generateSvg(many);
assert.equal(layout.count, 13);
assert.match(svg, new RegExp(`viewBox="0 0 540 ${layout.height}"`));
assert.match(svg, new RegExp(`viewBox="0 0 460 ${layout.height}"`));
assert.equal(svg.match(/class="bar-bg"/g).length, 13);
assert.doesNotMatch(svg, /rotate\(/);
});
@@ -183,8 +190,8 @@ test("generateSvg keeps bars inside the card", () => {
assert.equal(svg.match(/class="bar-bg"/g).length, 5, "one track per language row");
for (const [, x, width] of svg.matchAll(/<rect x="(\d+)" y="\d+" width="(\d+)"[^>]*class="bar-(?:bg|done|progress|low)"/g)) {
assert.ok(Number(x) >= 24, "bar must not overflow the left padding");
assert.ok(Number(x) + Number(width) <= 540 - 24, "bar must not overflow the right padding");
assert.ok(Number(x) >= 20, "bar must not overflow the left padding");
assert.ok(Number(x) + Number(width) <= 460 - 20, "bar must not overflow the right padding");
}
});
@@ -209,6 +216,6 @@ test("generateSvg escapes language names and handles empty data", () => {
const empty = generateSvg({ progress: [] });
assert.match(empty, /no translation data/);
assert.match(empty, /viewBox="0 0 540 96"/);
assert.match(empty, /viewBox="0 0 460 96"/);
assert.doesNotMatch(empty, /NaN|undefined/);
});