diff --git a/.changeset/5972-loading-group-per-language-uniformity.md b/.changeset/5972-loading-group-per-language-uniformity.md new file mode 100644 index 0000000000..5942bec9cc --- /dev/null +++ b/.changeset/5972-loading-group-per-language-uniformity.md @@ -0,0 +1,40 @@ +--- +'@object-ui/i18n': patch +--- + +The merged `Loading…` group now reads one way per language (objectui#5972). + +objectui#3878 converged every pack on the typographic ellipsis, and in doing so **merged** +the ASCII `Loading...` group into the U+2026 `Loading…` group. Nobody re-measured the +wording afterwards. Re-derived on today's tree by flattening all ten packs and comparing +exact values, the group is 10 keys — `lookup.loading`, `common.loading`, +`fields.recipient.loading`, `grid.import.historyLoading`, `grid.bulk.loading`, +`detail.loading`, `report.loading`, `dashboard.loading`, `auth.device.loading`, +`approvalsInbox.loadingMore` — and `de` rendered them four ways, `ko` two and `ar` two, +while en/zh/ja/fr/es/pt/ru were already unanimous. + +Three packs move, translation copy only — no key is added or removed, no `en` value +changes, and every value keeps its U+2026: + +- **de** → `Wird geladen…` on `detail.loading`, `report.loading` (were `Laden…`) and + `approvalsInbox.loadingMore` (was `Lädt…`). The passive is both the group majority and + the pack's dominant register for in-flight states generally. +- **ko** → `로딩 중…` on `fields.recipient.loading`, `grid.bulk.loading`, + `grid.import.historyLoading` and `approvalsInbox.loadingMore` (were `불러오는 중…`). + Majority, and it matches the pack's own pattern: `불러오는 중` is what `ko` uses when the + string names the thing being loaded, the bare form is `로딩 중`. +- **ar** → `جارٍ التحميل…` on `common.loading` and `detail.loading` (were `جاري التحميل…`). + This one is an orthography normalization rather than a wording choice: `جارٍ` is the + indefinite منقوص participle with tanwīn on the rāʾ, `جاري` the yāʾ-retaining form. + +`de` `auth.device.loading` deliberately stays `Lade…`. It is the one member whose outlier +spelling is coherent with its own screen: `DeviceAuthPage` renders that namespace's three +in-flight states together and `de` writes all three in the same first-person voice +(`Genehmige…`, `Ablehne…`, `Lade…`), the other two being outside this group. Converging it +alone would manufacture a fresh same-screen inconsistency, so it is reported as a fork and +pinned as a named exemption instead. + +`packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` gains the per-language +uniformity pin beside the glyph rule that created the group. The pin derives the group +from `en` and asserts its membership and per-language value counts **before** asserting +uniformity, so it cannot pass by matching nothing. diff --git a/packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts b/packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts index 8874f8c0de..68b4dcde09 100644 --- a/packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts +++ b/packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts @@ -219,3 +219,205 @@ describe('objectui#3878 — the ten packs spell the ellipsis U+2026 and only U+2 } }); }); + +/** + * objectui#5972 — the merged `Loading…` group, pinned for PER-LANGUAGE uniformity. + * + * ## Why this lives in the #3878 file + * + * #3878 is what created the group. Before it, `Loading…` (U+2026) named 8 keys + * and `Loading...` (ASCII) named 2 more; the glyph convergence merged them into + * one 10-key group whose `en` value is byte-identically `Loading…`. Nobody + * re-measured the wording afterwards, and the merged group turned out to be + * rendered four different ways in `de`, two in `ko` and two in `ar` — a split + * that only becomes visible once the two glyph groups are one group, which is + * exactly why the pin belongs beside the pass that merged them. + * + * This block adds a rule; it does not touch #3878's. The glyph invariant above + * ("no ASCII `...` in any value of any pack") still stands on its own, and + * nothing here weakens it: every value this pass moved keeps its U+2026. + * + * ## The ruling + * + * Triage, concentrated round 2026-08-25: converge each language pack to ONE + * rendering per language across the merged group, chosen by that pack's + * majority / most-idiomatic form. Translation copy only — no key is added or + * removed and no `en` value moves, so no contract changes and + * `check-i18n-en-drift.mjs` has no event to fire on. + * + * - **de** → `Wird geladen…` (was 6, plus `Laden…` ×2, `Lade…` ×1, `Lädt…` ×1). + * The passive is also the de pack's dominant register for in-flight states + * generally: 37 values whose `en` is a bare gerund render as `Wird …`. + * - **ko** → `로딩 중…` (was 6, plus `불러오는 중…` ×4). Majority, and it agrees + * with the pack's own pattern: `불러오는 중` is what `ko` uses when the string + * names the thing being loaded (`객체를 불러오는 중…`, `에이전트를 불러오는 중…`), + * while the bare form — which is what all ten of these keys are — is + * `… 로딩 중…` (`그리드 로딩 중…`, `차트 로딩 중…`, `양식 로딩 중…`). + * - **ar** → `جارٍ التحميل…` (was 8, plus `جاري التحميل…` ×2). See the separate + * note below: this one is a different class of defect from the other two. + * - en, zh, ja, fr, es, pt, ru were already unanimous and did not move. + * + * ## The `ar` pair is an ORTHOGRAPHY split, not a wording split + * + * `جارٍ` and `جاري` are not two translations; they are two spellings of one word. + * `جارٍ` (jārin) is the indefinite form of a منقوص participle — the final yāʾ + * drops and the rāʾ carries tanwīn (U+064D), which is the prescriptive MSA + * spelling here. `جاري` (U+064A, the yāʾ retained) is the definite/annexed form, + * widely used informally. So the two differ by one code point at the end of the + * first word, and the fix is a normalization rather than a choice of words. + * + * That matters because the split does NOT respect this group's boundary: pack + * wide, the standalone participle is `جارٍ` ×90 against `جاري` ×10, and only 2 of + * those 10 are in this group. Converging the 2 is what the ruling asks for and + * is what this pin can hold; the other 8 (`grid.loading`, `grid.refreshing`, + * `chart.loading`, `console.initializing`, `console.loadingSteps.*` ×3, + * `console.actions.retrying`) are a wider normalization filed separately rather + * than smuggled in here, the same way #3878 fenced off the per-package fallback + * tables. Read this block's green as a statement about this group only. + * + * ## The `de` fork, and why it is exempted BY NAME + * + * `auth.device.loading` is `Lade…` and stays `Lade…`. It is not an oversight and + * it is not this pass's to settle: it is the one member of the group whose + * outlier spelling is coherent with its own screen. `apps/console`'s + * `DeviceAuthPage.tsx` renders all three of that namespace's in-flight states, + * and de writes all three in the same first-person voice — `Genehmige…`, + * `Ablehne…`, `Lade…` — while `approving`/`denying` are OUTSIDE this group (their + * `en` is `Approving…`/`Denying…`). Converging `loading` alone would leave that + * one screen reading `Genehmige… / Ablehne… / Wird geladen…`: a new same-screen + * inconsistency manufactured by the very pass meant to remove one. Resolving it + * the other way — moving the whole namespace to the passive — is a copy-voice + * decision over keys this card does not fence in. + * + * So it is reported as a fork and pinned as an exemption. The pin asserts both + * halves: the value, and that it still DIFFERS from the converged rendering. If + * someone later converges it, this block goes red and the exemption row has to + * be deleted — a deliberate act, which is the point. + * + * Contrast `approvalsInbox.loadingMore`, which the card floated as a possible + * second fork (de `Lädt…`, ko `불러오는 중…`, on the theory that a *continuation* + * load may want its own wording). Measurement says no, in both packs: de writes + * all four of that namespace's other in-flight states passively + * (`Wird genehmigt…`, `Wird abgelehnt…`, `Wird zurückgezogen…`, + * `Wird erneut eingereicht…`), so `Lädt…` broke with its own neighbours rather + * than marking anything; and ko used the same `불러오는 중…` on three plainly + * INITIAL loads (`fields.recipient.loading`, `grid.bulk.loading`, + * `grid.import.historyLoading`), so it cannot have been marking continuation + * either. Both converge, and converging de there also restores the namespace. + */ + +/** The `en` value that defines membership in the group. */ +const LOADING_GROUP_EN = 'Loading…'; + +/** + * The group as measured on `main` @ `22ba9271f`. Pinned by name so the + * uniformity rule below cannot go green by the group quietly emptying out — + * a uniformity assertion over nothing passes, which is this pin's failure mode. + */ +const LOADING_GROUP = [ + 'approvalsInbox.loadingMore', + 'auth.device.loading', + 'common.loading', + 'dashboard.loading', + 'detail.loading', + 'fields.recipient.loading', + 'grid.bulk.loading', + 'grid.import.historyLoading', + 'lookup.loading', + 'report.loading', +] as const; + +/** + * Members held OUT of the uniformity rule, with the reason. See the fork note + * above. Exempting by name — rather than by loosening the rule — keeps the + * waiver countable and makes removing it a visible edit. + */ +const LOADING_GROUP_FORKS: ReadonlyArray<{ lang: Lang; key: string; value: string }> = [ + { lang: 'de', key: 'auth.device.loading', value: 'Lade…' }, +]; + +describe('objectui#5972 — each pack renders the merged `Loading…` group exactly one way', () => { + it('derives the group from en and finds exactly the ten keys pinned above', () => { + // Membership first, uniformity second. If `en` drifts — a key renamed, a + // value edited to `Loading more…`, a new `Loading…` key landing — the group + // this file reasons about is no longer the group it names, and the rule + // below would be silently measuring something else. + const derived = PACKS.en.filter(([, value]) => value === LOADING_GROUP_EN).map(([key]) => key); + expect(derived.length, 'the group emptied or changed size — re-measure before editing the list').toBe(10); + expect([...derived].sort()).toEqual([...LOADING_GROUP].sort()); + + // And every pack must actually define all ten. An absent key is a key-set + // fact owned by all-locales-key-parity, but if one went missing here the + // uniformity rule would happily pass over the survivors. + for (const lang of LANGS) { + const byKey = new Map(PACKS[lang]); + for (const key of LOADING_GROUP) { + expect(typeof byKey.get(key), `${lang} ${key} missing`).toBe('string'); + } + } + }); + + it('holds one rendering per language across the group, apart from the named forks', () => { + const forked = new Set(LOADING_GROUP_FORKS.map((f) => `${f.lang} ${f.key}`)); + const offenders: string[] = []; + + for (const lang of LANGS) { + const byKey = new Map(PACKS[lang]); + const ruled = LOADING_GROUP.filter((key) => !forked.has(`${lang} ${key}`)); + // Non-vacuity per language: 10 keys, minus this pack's exemptions. A + // count assertion here is what stops a collapsed pack or a typo'd key + // name from turning the set check below into a check of nothing. + const expectedCount = LOADING_GROUP.length - LOADING_GROUP_FORKS.filter((f) => f.lang === lang).length; + const values = ruled.map((key) => byKey.get(key) as string); + expect(values, `${lang}: wrong number of ruled values`).toHaveLength(expectedCount); + + const spellings = [...new Set(values)]; + if (spellings.length > 1) { + const byValue = new Map(); + for (const key of ruled) { + const value = byKey.get(key) as string; + byValue.set(value, [...(byValue.get(value) ?? []), key]); + } + offenders.push( + `${lang}: ${spellings.length} renderings — ` + + [...byValue.entries()] + .sort((a, b) => b[1].length - a[1].length) + .map(([value, keys]) => `${JSON.stringify(value)} on ${keys.join(', ')}`) + .join(' | '), + ); + } + } + + expect( + offenders, + 'These packs spell the same `en` string ("Loading…") more than one way across one merged ' + + 'group — objectui#5972. Converge on the pack majority, or, if the variant is genuinely ' + + 'wanted for its screen, add it to LOADING_GROUP_FORKS with the reason.', + ).toEqual([]); + }); + + it('pins the forked values so keeping them stays a deliberate act', () => { + // Both halves matter. The value pins what the fork actually says; the + // inequality pins that it is still a fork. Converge it later and this goes + // red until the row is removed, so the waiver cannot outlive its reason. + expect(LOADING_GROUP_FORKS).toHaveLength(1); + + for (const { lang, key, value } of LOADING_GROUP_FORKS) { + const byKey = new Map(PACKS[lang]); + expect(byKey.get(key), `${lang} ${key} moved — update or drop its LOADING_GROUP_FORKS row`).toBe(value); + + const converged = byKey.get(LOADING_GROUP.find((k) => k !== key) as string); + expect( + value, + `${lang} ${key} now matches the converged rendering — delete its LOADING_GROUP_FORKS row`, + ).not.toBe(converged); + } + + // The de first-person voice this fork is coherent with. Both keys are + // outside the group (their `en` is not `Loading…`), so nothing in this file + // rules on them; they are asserted because they are the fork's whole reason, + // and if they ever move to the passive the fork stops being justified. + expect(builtInLocales.de.auth.device.approving).toBe('Genehmige…'); + expect(builtInLocales.de.auth.device.denying).toBe('Ablehne…'); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 76155fb870..0798901776 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -85,7 +85,7 @@ const ar = { common: { addToFavorites: "إضافة إلى المفضلة", removeFromFavorites: "إزالة من المفضلة", - loading: "جاري التحميل…", + loading: "جارٍ التحميل…", save: "حفظ", discard: "تجاهل", cancel: "إلغاء", @@ -872,7 +872,7 @@ const ar = { relatedRecords: "{{count}} سجلات", relatedRecordOne: "{{count}} سجل", noRelatedRecords: "لا توجد سجلات ذات صلة", - loading: "جاري التحميل…", + loading: "جارٍ التحميل…", copyToClipboard: "نسخ إلى الحافظة", copied: "تم النسخ!", deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 6c55ec7de0..da32a9dd3a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -866,7 +866,7 @@ const de = { relatedRecords: "{{count}} Datensätze", relatedRecordOne: "{{count}} Datensatz", noRelatedRecords: "Keine verknüpften Datensätze gefunden", - loading: "Laden…", + loading: "Wird geladen…", copyToClipboard: "In Zwischenablage kopieren", copied: "Kopiert!", deleteConfirmation: "Sind Sie sicher, dass Sie diesen Datensatz löschen möchten?", @@ -2490,7 +2490,7 @@ const de = { columnsLabel: "Spalte", allLabel: "(Alle)", emptyLabel: "(Leer)", - loading: "Laden…", + loading: "Wird geladen…", failedToLoad: "Matrix konnte nicht geladen werden: {{message}}", needsAcross: "Matrixbericht erfordert mindestens ein `groupingsAcross`-Feld.", aggregate: { @@ -3020,7 +3020,7 @@ const de = { }, approvalsInbox: { loadMore: 'Mehr laden', - loadingMore: 'Lädt…', + loadingMore: 'Wird geladen…', loadedOf: '{{loaded}} von {{total}} geladen', actEscalate: 'SLA eskaliert', systemSlaActor: 'System (SLA)', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 2db09f193b..0235d26858 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -241,7 +241,7 @@ const ko = { }, recipient: { selectTypeFirst: "먼저 수신자 유형을 선택하세요.", - loading: "불러오는 중…", + loading: "로딩 중…", search: "검색…", empty: "일치하는 항목 없음", select: "수신자 선택", @@ -338,7 +338,7 @@ const ko = { undoing: "되돌리는 중…", done: "완료", selectPlaceholder: "선택…", - loading: "불러오는 중…", + loading: "로딩 중…", }, actions: "작업", edit: "편집", @@ -439,7 +439,7 @@ const ko = { historyDescription: "이 객체의 최근 가져오기입니다.", historyHint: "백그라운드 가져오기 작업, 최신순.", historyRefresh: "새로고침", - historyLoading: "불러오는 중…", + historyLoading: "로딩 중…", historyEmpty: "아직 가져오기가 없습니다.", historyUnsupported: "이 데이터 소스는 가져오기 기록을 지원하지 않습니다.", historyColStatus: "상태", @@ -3019,7 +3019,7 @@ const ko = { }, approvalsInbox: { loadMore: '더 보기', - loadingMore: '불러오는 중…', + loadingMore: '로딩 중…', loadedOf: '{{total}}개 중 {{loaded}}개 로드됨', actEscalate: 'SLA 에스컬레이션', systemSlaActor: '시스템(SLA)',