diff --git a/.changeset/i18n-stragglers-4375-4376.md b/.changeset/i18n-stragglers-4375-4376.md
new file mode 100644
index 000000000..1ecfd887f
--- /dev/null
+++ b/.changeset/i18n-stragglers-4375-4376.md
@@ -0,0 +1,23 @@
+---
+'@object-ui/fields': patch
+'@object-ui/i18n': patch
+'@object-ui/plugin-list': patch
+---
+
+i18n: the two search placeholders become pack values, and four values the packs served in English get translated
+
+**objectui#4375** — `ListView` and `LookupField` built their search placeholder as
+`t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
+in all ten locales on screens where objectui#3878 had converged everything else on
+U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
+was appended to right-to-left text). Both now read `table.search`, which is already
+the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
+`PeoplePicker` render it too — and is translated with the right ellipsis in all ten
+packs. No new keys.
+
+**objectui#4376** — `list.loading` served the English `Loading records…` in eight of
+the nine translation packs (`zh` alone had translated it); `designer.undo` and
+`designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
+`ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
+pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
+inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
diff --git a/packages/fields/src/complex-widgets.test.tsx b/packages/fields/src/complex-widgets.test.tsx
index 1b24371d4..7dc766c61 100644
--- a/packages/fields/src/complex-widgets.test.tsx
+++ b/packages/fields/src/complex-widgets.test.tsx
@@ -191,13 +191,14 @@ describe('Complex & Relationship Widgets', () => {
expect(mockDataSource.find).toHaveBeenCalledTimes(1);
});
- // Type in search. ASCII "..." and NOT U+2026, deliberately:
- // `LookupField.tsx:1085` builds this placeholder as
- // `t('common.search') + '...'`, so the ellipsis is a literal
- // concatenated in code and objectui#3878's pack convergence does not
- // reach it. Filed as objectui#4375 — when that lands, this moves.
+ // Type in search. U+2026 now, and it comes from a PACK VALUE:
+ // objectui#4375 retired the `t('common.search') + '...'`
+ // concatenation this used to pin, so the placeholder is `table.search`
+ // — read through `useFieldTranslation`, whose no-provider defaults map
+ // serves the `en` byte `Search…` in this test. It therefore moves with
+ // the pack from here on, and is covered by objectui#3878's glyph pin.
await act(async () => {
- fireEvent.change(screen.getByPlaceholderText('Search...'), {
+ fireEvent.change(screen.getByPlaceholderText('Search…'), {
target: { value: 'acme' },
});
});
@@ -466,7 +467,9 @@ describe('Complex & Relationship Widgets', () => {
expect(screen.getByText('Alpha')).toBeInTheDocument();
});
- const searchInput = screen.getByPlaceholderText('Search...');
+ // U+2026 from the `table.search` pack value — see the note on the
+ // `$search` test above (objectui#4375).
+ const searchInput = screen.getByPlaceholderText('Search…');
// Arrow down twice: -1 → 0 (Alpha) → 1 (Beta)
await act(async () => {
diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx
index da7c98627..7d09864dd 100644
--- a/packages/fields/src/widgets/LookupField.tsx
+++ b/packages/fields/src/widgets/LookupField.tsx
@@ -1082,7 +1082,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
diff --git a/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts
new file mode 100644
index 000000000..d765e6407
--- /dev/null
+++ b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts
@@ -0,0 +1,226 @@
+/**
+ * objectui#4376 — a value that is byte-identical to `en` inside a NON-LATIN pack
+ * is untranslated, and nothing in the i18n gate family could say so.
+ *
+ * `list.loading` was the specimen: `en` says `Loading records…` and so did
+ * `ja`, `ko`, `de`, `fr`, `es`, `pt`, `ru` and `ar` — eight of the nine packs
+ * served the raw English sentence, `zh` alone had translated it. A Japanese,
+ * Korean, Russian or Arabic user reading a list view saw a Latin-script English
+ * sentence in the middle of an otherwise translated screen, and in `ar` it was
+ * left-to-right text inside a right-to-left layout. Three more keys were in the
+ * same state: `designer.undo`, `designer.redo` and `appDesigner.snakeCaseHint`.
+ *
+ * ## Why no existing gate sees it — the value-domain blind spot, fourth instance
+ *
+ * This is the same hole as objectui#3582 / #3625 / #3810, one more shape:
+ *
+ * - `all-locales-key-parity` compares key SETS and placeholder shapes. All
+ * four keys were present in all ten packs with matching holes: full parity,
+ * green, and correctly so — asking a key-set test to judge meaning is how
+ * you get a test that owns nothing clearly.
+ * - `scripts/check-i18n-call-site-keys.mjs` asks only whether a key RESOLVES.
+ * All four resolved.
+ * - `scripts/check-i18n-en-drift.mjs` fires on an `en` VALUE CHANGE. These
+ * values were identical from the day the packs landed, so no drift event
+ * ever existed for it to fire on. It is blind to a value that was never
+ * right rather than one that stopped being right.
+ * - `ellipsis-glyph-3878.test.ts` scans the GLYPH, not the sentence. It is why
+ * `list.loading` surfaced at all — converging its ellipsis made the
+ * untranslated body of the value obvious — but its green says nothing here.
+ *
+ * ## The probe, and why it is scoped to the five non-Latin packs
+ *
+ * `value === en[key] && /[A-Za-z]{4,}/.test(value)`, over `zh`/`ja`/`ko`/`ru`/`ar`
+ * only. In a pack whose script is not Latin, a real translation of a run of four
+ * or more Latin letters essentially cannot be byte-equal to the English, so
+ * identity is decidable evidence of omission rather than a hint.
+ *
+ * It is deliberately NOT extended to `de`/`fr`/`es`/`pt`, where identity can be
+ * genuine — `Standard`, `Import`, `Text` and friends are simply the same word,
+ * and a probe that flagged them would be a false-positive generator whose
+ * allowlist grew without bound. That scoping has a real cost and it is stated
+ * rather than hidden: `list.loading` was ALSO untranslated in `de`/`fr`/`es`/`pt`,
+ * and `appDesigner.snakeCaseHint` in `pt`, and this file cannot see either. Both
+ * were fixed by hand in the same change; a Latin-pack omission still needs a
+ * human to notice it. objectui#3582 measured the inverse probe ("no ASCII English
+ * in a non-Latin pack") and found it produces false NEGATIVES; this is the
+ * narrower, decidable form of the same idea.
+ *
+ * The `{4,}` floor is what keeps acronyms and units out of the report without an
+ * allowlist entry each: `URL`, `API`, `ID`, `CSV`, `KB`, `v1` and the bare
+ * `{{count}}`-style format strings never reach the identity check at all.
+ *
+ * ## The allowlist is the cost, and it is keyed by KEY, not by key+lang
+ *
+ * 22 keys below are byte-identical to `en` in at least one non-Latin pack and
+ * are RIGHT that way — proper nouns, protocol and format names, example
+ * addresses, generated filenames, and pure interpolation. Each carries a
+ * one-line reason so the next reader can re-judge it instead of trusting it.
+ *
+ * Key-level rather than key+lang is a deliberate trade: it is looser (an entry
+ * excuses the identity in all five packs, not just the one that has it today)
+ * but it does not churn every time another pack legitimately adopts the same
+ * proper noun. `assert the allowlist has no dead entries` below is what pays for
+ * the looseness — an entry that stops being an identity anywhere has to be
+ * deleted, so the list cannot quietly accumulate cover for keys it no longer
+ * describes.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { builtInLocales } from '../locales/index';
+
+/**
+ * The packs whose script is not Latin. Identity with `en` is decidable evidence
+ * of omission here, and only here — see the header on why de/fr/es/pt are out.
+ */
+const NON_LATIN = ['zh', 'ja', 'ko', 'ru', 'ar'] as const;
+type NonLatin = (typeof NON_LATIN)[number];
+
+/** A run of Latin letters long enough that a non-Latin pack cannot mean it. */
+const LATIN_RUN = /[A-Za-z]{4,}/;
+
+/**
+ * Keys whose value is legitimately identical to `en` in a non-Latin pack.
+ * Every entry is a fact about the VALUE, not a suppression of a translation
+ * someone did not get to — if a key here ever acquires a real translation, its
+ * entry must be deleted, and the dead-entry assertion below enforces that.
+ */
+const LEGITIMATE_IDENTITIES: Record = {
+ // Proper nouns and product/protocol/format names — the same token in every language.
+ 'layout.metadata.jsonBadge': 'JSON — the format name, not a word.',
+ 'workflow.webhook': 'Webhook — the protocol term these packs use untranslated.',
+ 'workflow.webhookEvent': 'Webhook — same term, event-typed.',
+ 'publicForm.poweredBy': 'Powered by ObjectStack — product attribution; ja keeps the English wordmark line.',
+ 'connectAgent.apiKey.badge': 'headless — the literal mode name the CLI and API use.',
+ 'marketplace.pricing.freemium': 'Freemium — the pricing-tier term of art; ru keeps the loanword.',
+ 'console.settingsHub.beta': 'Beta — the release-stage badge, kept Latin in zh.',
+ 'console.settingsHub.categories.Beta': 'Beta — the same badge, reached by category name.',
+
+ // Loanwords these packs have adopted as their OWN vocabulary (checked against
+ // their neighbours: zh writes `Logo 链接` / `Logo 已上传…`, ru writes `Email подтверждён`).
+ 'appDesigner.logoUrl': 'Logo URL — zh/ko/ru/ar use `Logo` and the `URL` acronym as-is.',
+ 'appDesigner.faviconUrl': 'Favicon URL — same, and `Favicon` has no settled translation in these packs.',
+ 'organization.settings.logoLabel': 'Logo — zh vocabulary for this control; its whole block says `Logo`.',
+ 'auth.setup.emailLabel': 'Email — ru loanword; the rest of that block is localized (`ООО Пример`, `name@example.ru`).',
+
+ // Example values and identifiers — changing them would change what they mean.
+ 'auth.login.emailPlaceholder': 'name@example.com — RFC 2606 example address, an identifier.',
+ 'auth.register.emailPlaceholder': 'name@example.com — same.',
+ 'auth.forgotPassword.emailPlaceholder': 'name@example.com — same.',
+ 'auth.setup.emailPlaceholder': 'name@example.com — same (ru localizes it to name@example.ru).',
+ 'auth.setup.orgNamePlaceholder': 'Acme Inc. — the placeholder company name; ru localizes it, zh/ja keep it.',
+ 'grid.import.templateFileName':
+ '{{object}}-import-template — a generated FILENAME; it must stay ASCII and portable.',
+
+ // Pure interpolation and punctuation: there is no prose in these to translate.
+ 'fields.image.counter': '{{current}} / {{total}} — digits and a slash.',
+ 'marketplace.versionBadge': 'v{{version}} — version prefix plus the hole.',
+ 'appManagement.toast.bulkFailureEntry': '{{name}} ({{reason}}) — holes and parentheses only.',
+ 'collaboration.userStatusTitle': '{{name}} ({{status}}) — holes and parentheses only.',
+};
+
+/** Every string leaf of a pack, as `[dotted.path, value]`. */
+function flatten(pack: unknown, prefix = ''): Array<[string, string]> {
+ const out: Array<[string, string]> = [];
+ for (const [k, v] of Object.entries(pack as Record)) {
+ const path = prefix ? `${prefix}.${k}` : k;
+ if (typeof v === 'string') out.push([path, v]);
+ else if (v && typeof v === 'object') out.push(...flatten(v, path));
+ }
+ return out;
+}
+
+const EN = new Map(flatten(builtInLocales.en));
+const PACKS = Object.fromEntries(
+ NON_LATIN.map((lang) => [lang, new Map(flatten(builtInLocales[lang]))]),
+) as Record>;
+
+/** `[key, lang]` pairs where the pack serves the English value verbatim. */
+function findIdentities(): Array<{ key: string; lang: NonLatin; value: string }> {
+ const out: Array<{ key: string; lang: NonLatin; value: string }> = [];
+ for (const [key, enValue] of EN) {
+ if (!LATIN_RUN.test(enValue)) continue;
+ for (const lang of NON_LATIN) {
+ if (PACKS[lang].get(key) === enValue) out.push({ key, lang, value: enValue });
+ }
+ }
+ return out;
+}
+
+describe('objectui#4376 — a non-Latin pack does not serve the English value verbatim', () => {
+ it('holds no untranslated English value in zh/ja/ko/ru/ar outside the allowlist', () => {
+ // Non-vacuity first: a collapsed or empty pack satisfies every assertion
+ // below while checking nothing, which is the failure mode this whole gate
+ // family exists to avoid.
+ expect(EN.size, 'en pack collapsed').toBeGreaterThan(2000);
+ for (const lang of NON_LATIN) {
+ expect(PACKS[lang].size, `${lang} pack collapsed`).toBeGreaterThan(2000);
+ }
+
+ const offenders = findIdentities()
+ .filter(({ key }) => !(key in LEGITIMATE_IDENTITIES))
+ .map(({ key, lang, value }) => `${lang} ${key}: ${JSON.stringify(value)}`);
+
+ // Named, not counted: a regression has to say which key and which pack, and
+ // the message has to tell the next author both remedies — translate it, or
+ // justify the identity in the allowlist above.
+ expect(
+ offenders,
+ 'These values are byte-identical to `en` inside a pack whose script is not Latin, ' +
+ 'which means they were never translated — objectui#4376. ' +
+ `${offenders.length} value(s). Translate them, or, if the value is genuinely the same ` +
+ 'in that language (a proper noun, a format string, an identifier), add the key to ' +
+ 'LEGITIMATE_IDENTITIES above with a one-line reason.',
+ ).toEqual([]);
+ });
+
+ it('keeps the allowlist honest — no dead entries', () => {
+ // The complement. A key-level allowlist is only safe while every entry still
+ // describes a live identity; once a key is genuinely translated everywhere,
+ // its entry stops documenting anything and starts pre-authorising a future
+ // regression on that key. So a stale entry is a failure, not tidy-up debt.
+ const live = new Set(findIdentities().map(({ key }) => key));
+ const dead = Object.keys(LEGITIMATE_IDENTITIES).filter((key) => !live.has(key));
+ expect(
+ dead,
+ 'these allowlist entries no longer match any identity — delete them, ' +
+ 'or the allowlist is covering keys it no longer describes',
+ ).toEqual([]);
+
+ // And every entry must name a key that actually exists, so a rename cannot
+ // leave a silent hole behind.
+ const unknown = Object.keys(LEGITIMATE_IDENTITIES).filter((key) => !EN.has(key));
+ expect(unknown, 'these allowlist entries name keys the en pack does not define').toEqual([]);
+
+ for (const [key, reason] of Object.entries(LEGITIMATE_IDENTITIES)) {
+ expect(reason.length, `${key} has no reason recorded`).toBeGreaterThan(10);
+ }
+ });
+
+ it('pins the four keys this card repaired, so the fix cannot be reverted quietly', () => {
+ // The scan above goes green either by a value being translated OR by it
+ // being deleted/emptied — green because nothing is produced is not the same
+ // fact as green because the copy is right. Same complement as the one
+ // `ellipsis-glyph-3878.test.ts` carries for the same reason.
+ const REPAIRED = ['list.loading', 'designer.undo', 'designer.redo', 'appDesigner.snakeCaseHint'] as const;
+ for (const key of REPAIRED) {
+ const enValue = EN.get(key);
+ expect(typeof enValue, `en ${key} missing`).toBe('string');
+ for (const lang of NON_LATIN) {
+ const value = PACKS[lang].get(key);
+ expect(typeof value, `${lang} ${key} missing`).toBe('string');
+ expect((value as string).trim().length, `${lang} ${key} is empty`).toBeGreaterThan(0);
+ expect(value, `${lang} ${key} is back to the English value`).not.toBe(enValue);
+ }
+ }
+
+ // `appDesigner.snakeCaseHint` keeps the two IDENTIFIERS it is about. They are
+ // code, not prose — a pack that "translated" them would document a rule the
+ // validator does not enforce.
+ for (const lang of NON_LATIN) {
+ const hint = PACKS[lang].get('appDesigner.snakeCaseHint') as string;
+ expect(hint, `${lang} lost the snake_case identifier`).toContain('snake_case');
+ expect(hint, `${lang} lost the my_app example`).toContain('my_app');
+ }
+ });
+});
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 8dd3a14c1..e7518cdd2 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -535,7 +535,7 @@ const ar = {
},
},
refresh: "تحديث",
- loading: 'Loading records…',
+ loading: "جارٍ تحميل السجلات…",
recordCount: "{{count}} سجلات",
recordCountOne: "{{count}} سجل",
addRecord: "إضافة سجل",
@@ -775,8 +775,8 @@ const ar = {
readonlyTooltip: "عرض النظام — معرَّف في الكود، للقراءة فقط.",
},
designer: {
- undo: 'Undo',
- redo: 'Redo',
+ undo: "تراجع",
+ redo: "إعادة",
},
detail: {
editInlineHint: "انقر نقرًا مزدوجًا للتحرير",
@@ -1133,7 +1133,7 @@ const ar = {
faviconUrl: "Favicon URL",
preview: "معاينة",
complete: "إكمال",
- snakeCaseHint: "Must be snake_case (e.g. my_app)",
+ snakeCaseHint: "يجب أن يكون بصيغة snake_case (مثل my_app)",
modeEdit: "تعديل",
modePreview: "معاينة",
modeCode: "كود",
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index a9b3b0cd9..caf58e6f4 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -531,7 +531,7 @@ const de = {
},
},
refresh: "Aktualisieren",
- loading: 'Loading records…',
+ loading: "Datensätze werden geladen…",
recordCount: "{{count}} Datensätze",
recordCountOne: "{{count}} Datensatz",
addRecord: "Datensatz hinzufügen",
@@ -771,8 +771,8 @@ const de = {
readonlyTooltip: "Systemansicht — im Code definiert, schreibgeschützt.",
},
designer: {
- undo: 'Undo',
- redo: 'Redo',
+ undo: "Rückgängig",
+ redo: "Wiederholen",
},
detail: {
editInlineHint: "Zum Bearbeiten doppelklicken",
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index 674eb91f1..c918fc656 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -535,7 +535,7 @@ const es = {
},
},
refresh: "Actualizar",
- loading: 'Loading records…',
+ loading: "Cargando registros…",
recordCount: "{{count}} registros",
recordCountOne: "{{count}} registro",
addRecord: "Agregar registro",
@@ -775,8 +775,8 @@ const es = {
readonlyTooltip: "Vista del sistema — definida en el código, solo lectura.",
},
designer: {
- undo: 'Undo',
- redo: 'Redo',
+ undo: "Deshacer",
+ redo: "Rehacer",
},
detail: {
editInlineHint: "Haga doble clic para editar",
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index d1ed6c85d..d40488b25 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -531,7 +531,7 @@ const fr = {
},
},
refresh: "Actualiser",
- loading: 'Loading records…',
+ loading: "Chargement des enregistrements…",
recordCount: "{{count}} enregistrements",
recordCountOne: "{{count}} enregistrement",
addRecord: "Ajouter un enregistrement",
@@ -771,8 +771,8 @@ const fr = {
readonlyTooltip: "Vue système — définie dans le code, en lecture seule.",
},
designer: {
- undo: 'Undo',
- redo: 'Redo',
+ undo: "Annuler",
+ redo: "Rétablir",
},
detail: {
editInlineHint: "Double-cliquez pour modifier",
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 3fe19bed9..0b5fb1293 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -531,7 +531,7 @@ const ja = {
},
},
refresh: "更新",
- loading: 'Loading records…',
+ loading: "レコードを読み込み中…",
recordCount: "{{count}} 件のレコード",
recordCountOne: "{{count}} 件のレコード",
addRecord: "レコードを追加",
@@ -771,8 +771,8 @@ const ja = {
readonlyTooltip: "システムビュー — コードで定義され、読み取り専用です。",
},
designer: {
- undo: 'Undo',
- redo: 'Redo',
+ undo: "元に戻す",
+ redo: "やり直す",
},
detail: {
editInlineHint: "ダブルクリックで編集",
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index 75d2a8bc7..02a1a953c 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -531,7 +531,7 @@ const ko = {
},
},
refresh: "새로고침",
- loading: 'Loading records…',
+ loading: "레코드 로딩 중…",
recordCount: "{{count}}개 레코드",
recordCountOne: "{{count}}개 레코드",
addRecord: "레코드 추가",
@@ -771,8 +771,8 @@ const ko = {
readonlyTooltip: "시스템 보기 — 코드에 정의되어 있으며 읽기 전용입니다.",
},
designer: {
- undo: 'Undo',
- redo: 'Redo',
+ undo: "실행 취소",
+ redo: "다시 실행",
},
detail: {
editInlineHint: "더블클릭하여 편집",
@@ -1126,7 +1126,7 @@ const ko = {
faviconUrl: "Favicon URL",
preview: "미리보기",
complete: "완료",
- snakeCaseHint: "Must be snake_case (e.g. my_app)",
+ snakeCaseHint: "snake_case 형식이어야 합니다 (예: my_app)",
modeEdit: "편집",
modePreview: "미리보기",
modeCode: "코드",
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 91467202d..5752b61c0 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -530,7 +530,7 @@ const pt = {
},
},
refresh: "Atualizar",
- loading: 'Loading records…',
+ loading: "Carregando registros…",
recordCount: "{{count}} registros",
recordCountOne: "{{count}} registro",
addRecord: "Adicionar registro",
@@ -770,8 +770,8 @@ const pt = {
readonlyTooltip: "Exibição do sistema — definida no código, somente leitura.",
},
designer: {
- undo: 'Undo',
- redo: 'Redo',
+ undo: "Desfazer",
+ redo: "Refazer",
},
detail: {
editInlineHint: "Clique duas vezes para editar",
@@ -1125,7 +1125,7 @@ const pt = {
faviconUrl: "Favicon URL",
preview: "Pré-visualização",
complete: "Concluir",
- snakeCaseHint: "Must be snake_case (e.g. my_app)",
+ snakeCaseHint: "Deve ser snake_case (ex. my_app)",
modeEdit: "Editar",
modePreview: "Pré-visualizar",
modeCode: "Código",
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index 54b93a960..f8b8c5296 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -537,7 +537,7 @@ const ru = {
},
},
refresh: "Обновить",
- loading: 'Loading records…',
+ loading: "Загрузка записей…",
recordCount: "{{count}} записей",
recordCountOne: "{{count}} запись",
addRecord: "Добавить запись",
@@ -777,8 +777,8 @@ const ru = {
readonlyTooltip: "Системное представление — определено в коде, только для чтения.",
},
designer: {
- undo: 'Undo',
- redo: 'Redo',
+ undo: "Отменить",
+ redo: "Повторить",
},
detail: {
editInlineHint: "Дважды щёлкните для редактирования",
@@ -1136,7 +1136,7 @@ const ru = {
faviconUrl: "Favicon URL",
preview: "Предпросмотр",
complete: "Завершить",
- snakeCaseHint: "Must be snake_case (e.g. my_app)",
+ snakeCaseHint: "Должно быть в формате snake_case (например, my_app)",
modeEdit: "Редактировать",
modePreview: "Предпросмотр",
modeCode: "Код",
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 02bfff8b0..acd976195 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -792,8 +792,8 @@ const zh = {
readonlyTooltip: '系统视图 — 由代码定义,只读。',
},
designer: {
- undo: 'Undo',
- redo: 'Redo',
+ undo: '撤销',
+ redo: '重做',
},
detail: {
back: '返回',
diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx
index c5839d16f..d78809600 100644
--- a/packages/plugin-list/src/ListView.tsx
+++ b/packages/plugin-list/src/ListView.tsx
@@ -451,7 +451,26 @@ const LIST_DEFAULT_TRANSLATIONS: Record = {
'list.loadErrorRejectedTitle': 'This view’s query was rejected',
'list.loadErrorRejectedMessage': 'The server could not process this view’s filter or query options. Clearing the filters usually fixes it; if the view is saved this way, an administrator needs to correct it.',
'list.retry': 'Retry',
+ // The bare NOUN, for the search button's tooltip. It is deliberately NOT the
+ // input placeholder: that is `table.search` below (objectui#4375).
'list.search': 'Search',
+ // Placeholder of the search popover's input. Borrowed from the `table.*`
+ // namespace rather than minted as `list.searchPlaceholder`, on the same
+ // reasoning as `detail.recordDetail` below: `table.search` is already THE
+ // search-input placeholder key in this repo — `data-table`, `RecordPickerDialog`
+ // and `PeoplePicker` all render it — and one control should not get two
+ // translations that can drift apart in a locale.
+ //
+ // Until objectui#4375 this read `t('list.search') + '...'`, so the ellipsis was
+ // a literal concatenated in code: it stayed ASCII in all ten locales (on a
+ // screen where objectui#3878 had converged everything else on U+2026), and no
+ // pack could opt out of it — sharpest in `ar`, which got a left-to-right run
+ // appended to right-to-left text. As a pack value the ellipsis is the ar
+ // pack's own (`بحث…`) and the bidi algorithm places it at the logical end.
+ //
+ // Byte-identical to `en`, like every entry here — a provider-less host renders
+ // THIS copy, so a divergence would make the two paths disagree on one control.
+ 'table.search': 'Search…',
'list.filter': 'Filter',
'list.filterRecords': 'Filter Records',
'list.sort': 'Sort',
@@ -2826,7 +2845,7 @@ export const ListView = React.forwardRef(({
handleSearchChange(e.target.value)}
className="pl-7 h-8 text-xs"
diff --git a/packages/plugin-list/src/__tests__/ListView.test.tsx b/packages/plugin-list/src/__tests__/ListView.test.tsx
index 1c906d87f..bae8e3be5 100644
--- a/packages/plugin-list/src/__tests__/ListView.test.tsx
+++ b/packages/plugin-list/src/__tests__/ListView.test.tsx
@@ -962,6 +962,38 @@ describe('ListView', () => {
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
+ it('takes the search placeholder from a pack value, not a code concatenation', () => {
+ // objectui#4375. This used to be `t('list.search') + '...'` — the ellipsis
+ // was a literal appended in code, so it stayed ASCII in all ten locales
+ // (while objectui#3878 had converged every other placeholder on U+2026)
+ // and no pack could opt out of it. The regex the sibling tests above use
+ // (`/search/i`) matches BOTH spellings, so nothing here could see the
+ // difference; this is the byte-exact pin that can.
+ //
+ // There is no LocalizationProvider in these tests, so the string below is
+ // served by `LIST_DEFAULT_TRANSLATIONS`, whose contract is to stay
+ // byte-identical to the `en` pack — which makes this an assertion about
+ // that contract as much as about the call site.
+ const schema: ListViewSchema = {
+ type: 'list-view',
+ objectName: 'contacts',
+ viewType: 'grid',
+ fields: ['name', 'email'],
+ };
+
+ renderWithProvider();
+ fireEvent.click(screen.getByTestId('search-icon-button'));
+
+ // U+2026, and it comes from `table.search`.
+ expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument();
+
+ // …while the trigger's tooltip keeps the bare NOUN `list.search`. The two
+ // are deliberately different keys: giving `list.search` the ellipsis would
+ // have put one on this tooltip too.
+ expect(screen.getByTestId('search-icon-button')).toHaveAttribute('title', 'Search');
+ });
+
it('should highlight search icon when search term is active', () => {
const schema: ListViewSchema = {
type: 'list-view',