Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/5972-loading-group-per-language-uniformity.md
Original file line numberDiff line numberDiff line change
@@ -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.
202 changes: 202 additions & 0 deletions packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string[]>();
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…');
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,7 +85,7 @@ const ar = {
common: {
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
save: "حفظ",
discard: "تجاهل",
cancel: "إلغاء",
Expand DownExpand Up@@ -872,7 +872,7 @@ const ar = {
relatedRecords: "{{count}} سجلات",
relatedRecordOne: "{{count}} سجل",
noRelatedRecords: "لا توجد سجلات ذات صلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
copyToClipboard: "نسخ إلى الحافظة",
copied: "تم النسخ!",
deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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?",
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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)',
Expand Down
8 changes: 4 additions & 4 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ const ko = {
},
recipient: {
selectTypeFirst: "먼저 수신자 유형을 선택하세요.",
loading: "불러오는 중…",
loading: "로딩 중…",
search: "검색…",
empty: "일치하는 항목 없음",
select: "수신자 선택",
Expand DownExpand Up@@ -338,7 +338,7 @@ const ko = {
undoing: "되돌리는 중…",
done: "완료",
selectPlaceholder: "선택…",
loading: "불러오는 중…",
loading: "로딩 중…",
},
actions: "작업",
edit: "편집",
Expand DownExpand Up@@ -439,7 +439,7 @@ const ko = {
historyDescription: "이 객체의 최근 가져오기입니다.",
historyHint: "백그라운드 가져오기 작업, 최신순.",
historyRefresh: "새로고침",
historyLoading: "불러오는 중…",
historyLoading: "로딩 중…",
historyEmpty: "아직 가져오기가 없습니다.",
historyUnsupported: "이 데이터 소스는 가져오기 기록을 지원하지 않습니다.",
historyColStatus: "상태",
Expand DownExpand Up@@ -3019,7 +3019,7 @@ const ko = {
},
approvalsInbox: {
loadMore: '더 보기',
loadingMore: '불러오는 중…',
loadingMore: '로딩 중…',
loadedOf: '{{total}}개 중 {{loaded}}개 로드됨',
actEscalate: 'SLA 에스컬레이션',
systemSlaActor: '시스템(SLA)',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(i18n): converge the merged `Loading…` group to one rendering per language by claude[bot] · Pull Request #6612 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/5972-loading-group-per-language-uniformity.md
Original file line numberDiff line numberDiff line change
@@ -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.
202 changes: 202 additions & 0 deletions packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string[]>();
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…');
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,7 +85,7 @@ const ar = {
common: {
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
save: "حفظ",
discard: "تجاهل",
cancel: "إلغاء",
Expand DownExpand Up@@ -872,7 +872,7 @@ const ar = {
relatedRecords: "{{count}} سجلات",
relatedRecordOne: "{{count}} سجل",
noRelatedRecords: "لا توجد سجلات ذات صلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
copyToClipboard: "نسخ إلى الحافظة",
copied: "تم النسخ!",
deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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?",
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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)',
Expand Down
8 changes: 4 additions & 4 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ const ko = {
},
recipient: {
selectTypeFirst: "먼저 수신자 유형을 선택하세요.",
loading: "불러오는 중…",
loading: "로딩 중…",
search: "검색…",
empty: "일치하는 항목 없음",
select: "수신자 선택",
Expand DownExpand Up@@ -338,7 +338,7 @@ const ko = {
undoing: "되돌리는 중…",
done: "완료",
selectPlaceholder: "선택…",
loading: "불러오는 중…",
loading: "로딩 중…",
},
actions: "작업",
edit: "편집",
Expand DownExpand Up@@ -439,7 +439,7 @@ const ko = {
historyDescription: "이 객체의 최근 가져오기입니다.",
historyHint: "백그라운드 가져오기 작업, 최신순.",
historyRefresh: "새로고침",
historyLoading: "불러오는 중…",
historyLoading: "로딩 중…",
historyEmpty: "아직 가져오기가 없습니다.",
historyUnsupported: "이 데이터 소스는 가져오기 기록을 지원하지 않습니다.",
historyColStatus: "상태",
Expand DownExpand Up@@ -3019,7 +3019,7 @@ const ko = {
},
approvalsInbox: {
loadMore: '더 보기',
loadingMore: '불러오는 중…',
loadingMore: '로딩 중…',
loadedOf: '{{total}}개 중 {{loaded}}개 로드됨',
actEscalate: 'SLA 에스컬레이션',
systemSlaActor: '시스템(SLA)',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(i18n): converge the merged `Loading…` group to one rendering per language by claude[bot] · Pull Request #6612 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/5972-loading-group-per-language-uniformity.md
Original file line numberDiff line numberDiff line change
@@ -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.
202 changes: 202 additions & 0 deletions packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string[]>();
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…');
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,7 +85,7 @@ const ar = {
common: {
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
save: "حفظ",
discard: "تجاهل",
cancel: "إلغاء",
Expand DownExpand Up@@ -872,7 +872,7 @@ const ar = {
relatedRecords: "{{count}} سجلات",
relatedRecordOne: "{{count}} سجل",
noRelatedRecords: "لا توجد سجلات ذات صلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
copyToClipboard: "نسخ إلى الحافظة",
copied: "تم النسخ!",
deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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?",
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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)',
Expand Down
8 changes: 4 additions & 4 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ const ko = {
},
recipient: {
selectTypeFirst: "먼저 수신자 유형을 선택하세요.",
loading: "불러오는 중…",
loading: "로딩 중…",
search: "검색…",
empty: "일치하는 항목 없음",
select: "수신자 선택",
Expand DownExpand Up@@ -338,7 +338,7 @@ const ko = {
undoing: "되돌리는 중…",
done: "완료",
selectPlaceholder: "선택…",
loading: "불러오는 중…",
loading: "로딩 중…",
},
actions: "작업",
edit: "편집",
Expand DownExpand Up@@ -439,7 +439,7 @@ const ko = {
historyDescription: "이 객체의 최근 가져오기입니다.",
historyHint: "백그라운드 가져오기 작업, 최신순.",
historyRefresh: "새로고침",
historyLoading: "불러오는 중…",
historyLoading: "로딩 중…",
historyEmpty: "아직 가져오기가 없습니다.",
historyUnsupported: "이 데이터 소스는 가져오기 기록을 지원하지 않습니다.",
historyColStatus: "상태",
Expand DownExpand Up@@ -3019,7 +3019,7 @@ const ko = {
},
approvalsInbox: {
loadMore: '더 보기',
loadingMore: '불러오는 중…',
loadingMore: '로딩 중…',
loadedOf: '{{total}}개 중 {{loaded}}개 로드됨',
actEscalate: 'SLA 에스컬레이션',
systemSlaActor: '시스템(SLA)',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(i18n): converge the merged `Loading…` group to one rendering per language by claude[bot] · Pull Request #6612 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/5972-loading-group-per-language-uniformity.md
Original file line numberDiff line numberDiff line change
@@ -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.
202 changes: 202 additions & 0 deletions packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string[]>();
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…');
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,7 +85,7 @@ const ar = {
common: {
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
save: "حفظ",
discard: "تجاهل",
cancel: "إلغاء",
Expand DownExpand Up@@ -872,7 +872,7 @@ const ar = {
relatedRecords: "{{count}} سجلات",
relatedRecordOne: "{{count}} سجل",
noRelatedRecords: "لا توجد سجلات ذات صلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
copyToClipboard: "نسخ إلى الحافظة",
copied: "تم النسخ!",
deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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?",
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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)',
Expand Down
8 changes: 4 additions & 4 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ const ko = {
},
recipient: {
selectTypeFirst: "먼저 수신자 유형을 선택하세요.",
loading: "불러오는 중…",
loading: "로딩 중…",
search: "검색…",
empty: "일치하는 항목 없음",
select: "수신자 선택",
Expand DownExpand Up@@ -338,7 +338,7 @@ const ko = {
undoing: "되돌리는 중…",
done: "완료",
selectPlaceholder: "선택…",
loading: "불러오는 중…",
loading: "로딩 중…",
},
actions: "작업",
edit: "편집",
Expand DownExpand Up@@ -439,7 +439,7 @@ const ko = {
historyDescription: "이 객체의 최근 가져오기입니다.",
historyHint: "백그라운드 가져오기 작업, 최신순.",
historyRefresh: "새로고침",
historyLoading: "불러오는 중…",
historyLoading: "로딩 중…",
historyEmpty: "아직 가져오기가 없습니다.",
historyUnsupported: "이 데이터 소스는 가져오기 기록을 지원하지 않습니다.",
historyColStatus: "상태",
Expand DownExpand Up@@ -3019,7 +3019,7 @@ const ko = {
},
approvalsInbox: {
loadMore: '더 보기',
loadingMore: '불러오는 중…',
loadingMore: '로딩 중…',
loadedOf: '{{total}}개 중 {{loaded}}개 로드됨',
actEscalate: 'SLA 에스컬레이션',
systemSlaActor: '시스템(SLA)',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(i18n): converge the merged `Loading…` group to one rendering per language by claude[bot] · Pull Request #6612 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/5972-loading-group-per-language-uniformity.md
Original file line numberDiff line numberDiff line change
@@ -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.
202 changes: 202 additions & 0 deletions packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string[]>();
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…');
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,7 +85,7 @@ const ar = {
common: {
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
save: "حفظ",
discard: "تجاهل",
cancel: "إلغاء",
Expand DownExpand Up@@ -872,7 +872,7 @@ const ar = {
relatedRecords: "{{count}} سجلات",
relatedRecordOne: "{{count}} سجل",
noRelatedRecords: "لا توجد سجلات ذات صلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
copyToClipboard: "نسخ إلى الحافظة",
copied: "تم النسخ!",
deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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?",
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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)',
Expand Down
8 changes: 4 additions & 4 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ const ko = {
},
recipient: {
selectTypeFirst: "먼저 수신자 유형을 선택하세요.",
loading: "불러오는 중…",
loading: "로딩 중…",
search: "검색…",
empty: "일치하는 항목 없음",
select: "수신자 선택",
Expand DownExpand Up@@ -338,7 +338,7 @@ const ko = {
undoing: "되돌리는 중…",
done: "완료",
selectPlaceholder: "선택…",
loading: "불러오는 중…",
loading: "로딩 중…",
},
actions: "작업",
edit: "편집",
Expand DownExpand Up@@ -439,7 +439,7 @@ const ko = {
historyDescription: "이 객체의 최근 가져오기입니다.",
historyHint: "백그라운드 가져오기 작업, 최신순.",
historyRefresh: "새로고침",
historyLoading: "불러오는 중…",
historyLoading: "로딩 중…",
historyEmpty: "아직 가져오기가 없습니다.",
historyUnsupported: "이 데이터 소스는 가져오기 기록을 지원하지 않습니다.",
historyColStatus: "상태",
Expand DownExpand Up@@ -3019,7 +3019,7 @@ const ko = {
},
approvalsInbox: {
loadMore: '더 보기',
loadingMore: '불러오는 중…',
loadingMore: '로딩 중…',
loadedOf: '{{total}}개 중 {{loaded}}개 로드됨',
actEscalate: 'SLA 에스컬레이션',
systemSlaActor: '시스템(SLA)',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(i18n): converge the merged `Loading…` group to one rendering per language by claude[bot] · Pull Request #6612 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/5972-loading-group-per-language-uniformity.md
Original file line numberDiff line numberDiff line change
@@ -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.
202 changes: 202 additions & 0 deletions packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string[]>();
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…');
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,7 +85,7 @@ const ar = {
common: {
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
save: "حفظ",
discard: "تجاهل",
cancel: "إلغاء",
Expand DownExpand Up@@ -872,7 +872,7 @@ const ar = {
relatedRecords: "{{count}} سجلات",
relatedRecordOne: "{{count}} سجل",
noRelatedRecords: "لا توجد سجلات ذات صلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
copyToClipboard: "نسخ إلى الحافظة",
copied: "تم النسخ!",
deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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?",
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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)',
Expand Down
8 changes: 4 additions & 4 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ const ko = {
},
recipient: {
selectTypeFirst: "먼저 수신자 유형을 선택하세요.",
loading: "불러오는 중…",
loading: "로딩 중…",
search: "검색…",
empty: "일치하는 항목 없음",
select: "수신자 선택",
Expand DownExpand Up@@ -338,7 +338,7 @@ const ko = {
undoing: "되돌리는 중…",
done: "완료",
selectPlaceholder: "선택…",
loading: "불러오는 중…",
loading: "로딩 중…",
},
actions: "작업",
edit: "편집",
Expand DownExpand Up@@ -439,7 +439,7 @@ const ko = {
historyDescription: "이 객체의 최근 가져오기입니다.",
historyHint: "백그라운드 가져오기 작업, 최신순.",
historyRefresh: "새로고침",
historyLoading: "불러오는 중…",
historyLoading: "로딩 중…",
historyEmpty: "아직 가져오기가 없습니다.",
historyUnsupported: "이 데이터 소스는 가져오기 기록을 지원하지 않습니다.",
historyColStatus: "상태",
Expand DownExpand Up@@ -3019,7 +3019,7 @@ const ko = {
},
approvalsInbox: {
loadMore: '더 보기',
loadingMore: '불러오는 중…',
loadingMore: '로딩 중…',
loadedOf: '{{total}}개 중 {{loaded}}개 로드됨',
actEscalate: 'SLA 에스컬레이션',
systemSlaActor: '시스템(SLA)',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(i18n): converge the merged `Loading…` group to one rendering per language by claude[bot] · Pull Request #6612 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/5972-loading-group-per-language-uniformity.md
Original file line numberDiff line numberDiff line change
@@ -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.
202 changes: 202 additions & 0 deletions packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string[]>();
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…');
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,7 +85,7 @@ const ar = {
common: {
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
save: "حفظ",
discard: "تجاهل",
cancel: "إلغاء",
Expand DownExpand Up@@ -872,7 +872,7 @@ const ar = {
relatedRecords: "{{count}} سجلات",
relatedRecordOne: "{{count}} سجل",
noRelatedRecords: "لا توجد سجلات ذات صلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
copyToClipboard: "نسخ إلى الحافظة",
copied: "تم النسخ!",
deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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?",
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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)',
Expand Down
8 changes: 4 additions & 4 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ const ko = {
},
recipient: {
selectTypeFirst: "먼저 수신자 유형을 선택하세요.",
loading: "불러오는 중…",
loading: "로딩 중…",
search: "검색…",
empty: "일치하는 항목 없음",
select: "수신자 선택",
Expand DownExpand Up@@ -338,7 +338,7 @@ const ko = {
undoing: "되돌리는 중…",
done: "완료",
selectPlaceholder: "선택…",
loading: "불러오는 중…",
loading: "로딩 중…",
},
actions: "작업",
edit: "편집",
Expand DownExpand Up@@ -439,7 +439,7 @@ const ko = {
historyDescription: "이 객체의 최근 가져오기입니다.",
historyHint: "백그라운드 가져오기 작업, 최신순.",
historyRefresh: "새로고침",
historyLoading: "불러오는 중…",
historyLoading: "로딩 중…",
historyEmpty: "아직 가져오기가 없습니다.",
historyUnsupported: "이 데이터 소스는 가져오기 기록을 지원하지 않습니다.",
historyColStatus: "상태",
Expand DownExpand Up@@ -3019,7 +3019,7 @@ const ko = {
},
approvalsInbox: {
loadMore: '더 보기',
loadingMore: '불러오는 중…',
loadingMore: '로딩 중…',
loadedOf: '{{total}}개 중 {{loaded}}개 로드됨',
actEscalate: 'SLA 에스컬레이션',
systemSlaActor: '시스템(SLA)',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(i18n): converge the merged `Loading…` group to one rendering per language by claude[bot] · Pull Request #6612 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/5972-loading-group-per-language-uniformity.md
Original file line numberDiff line numberDiff line change
@@ -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.
202 changes: 202 additions & 0 deletions packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string[]>();
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…');
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,7 +85,7 @@ const ar = {
common: {
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
save: "حفظ",
discard: "تجاهل",
cancel: "إلغاء",
Expand DownExpand Up@@ -872,7 +872,7 @@ const ar = {
relatedRecords: "{{count}} سجلات",
relatedRecordOne: "{{count}} سجل",
noRelatedRecords: "لا توجد سجلات ذات صلة",
loading: "جاري التحميل…",
loading: "جارٍ التحميل…",
copyToClipboard: "نسخ إلى الحافظة",
copied: "تم النسخ!",
deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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?",
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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)',
Expand Down
8 changes: 4 additions & 4 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ const ko = {
},
recipient: {
selectTypeFirst: "먼저 수신자 유형을 선택하세요.",
loading: "불러오는 중…",
loading: "로딩 중…",
search: "검색…",
empty: "일치하는 항목 없음",
select: "수신자 선택",
Expand DownExpand Up@@ -338,7 +338,7 @@ const ko = {
undoing: "되돌리는 중…",
done: "완료",
selectPlaceholder: "선택…",
loading: "불러오는 중…",
loading: "로딩 중…",
},
actions: "작업",
edit: "편집",
Expand DownExpand Up@@ -439,7 +439,7 @@ const ko = {
historyDescription: "이 객체의 최근 가져오기입니다.",
historyHint: "백그라운드 가져오기 작업, 최신순.",
historyRefresh: "새로고침",
historyLoading: "불러오는 중…",
historyLoading: "로딩 중…",
historyEmpty: "아직 가져오기가 없습니다.",
historyUnsupported: "이 데이터 소스는 가져오기 기록을 지원하지 않습니다.",
historyColStatus: "상태",
Expand DownExpand Up@@ -3019,7 +3019,7 @@ const ko = {
},
approvalsInbox: {
loadMore: '더 보기',
loadingMore: '불러오는 중…',
loadingMore: '로딩 중…',
loadedOf: '{{total}}개 중 {{loaded}}개 로드됨',
actEscalate: 'SLA 에스컬레이션',
systemSlaActor: '시스템(SLA)',
Expand Down
Loading