From f82cc875ff718c13ca287a138898f6ca30cff9b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:06:27 +0000 Subject: [PATCH 1/4] test(i18n): byte-identity probe for untranslated values in the non-Latin packs (#4376) Guard first, RED on the four defect keys and nothing else: 17 pack/key pairs across zh/ja/ko/ru/ar, with the 22 legitimate identities absorbed by an explicit allowlist. --- .../untranslated-identity-4376.test.ts | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 packages/i18n/src/__tests__/untranslated-identity-4376.test.ts 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'); + } + }); +}); From 7b20b2ff4720e3e841176a923a30d79eead1eab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:08:15 +0000 Subject: [PATCH 2/4] fix(i18n): translate the four values the packs were serving in English (#4376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list.loading in the eight packs that copied en (zh had translated it), designer.undo/redo in all nine, appDesigner.snakeCaseHint in ko/ru/ar plus pt — pt is the same omission, invisible to the card's non-Latin probe but proven by de/fr/es having translated it. undo/redo copy each pack's own established edit-history vocabulary: appDesigner.undo/redo, gantt.toolbar.undo/redo and workflow.undo/redo already agree byte-for-byte in all nine packs, so this was a settled call, not a fresh one. --- packages/i18n/src/locales/ar.ts | 8 ++++---- packages/i18n/src/locales/de.ts | 6 +++--- packages/i18n/src/locales/es.ts | 6 +++--- packages/i18n/src/locales/fr.ts | 6 +++--- packages/i18n/src/locales/ja.ts | 6 +++--- packages/i18n/src/locales/ko.ts | 8 ++++---- packages/i18n/src/locales/pt.ts | 8 ++++---- packages/i18n/src/locales/ru.ts | 8 ++++---- packages/i18n/src/locales/zh.ts | 4 ++-- 9 files changed, 30 insertions(+), 30 deletions(-) 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: '返回', From 6ca7c3151c1884f2717bc910dc42ecacf61f4c95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:23:27 +0000 Subject: [PATCH 3/4] fix(i18n): the two search placeholders are pack values, not code concatenations (#4375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListView and LookupField built the placeholder as t(key) + '...', so the ellipsis was a literal in code: ASCII in all ten locales, and no pack could opt out of it. Both now read table.search — already THE search-input placeholder key here (data-table, RecordPickerDialog, PeoplePicker) and already translated with U+2026 in all ten packs. Zero new keys. The two complex-widgets.test.tsx assertions that pinned the concatenated truth move to the pack value. --- .changeset/i18n-stragglers-4375-4376.md | 23 ++++++++++++++++++++ packages/fields/src/complex-widgets.test.tsx | 17 +++++++++------ packages/fields/src/widgets/LookupField.tsx | 2 +- packages/plugin-list/src/ListView.tsx | 21 +++++++++++++++++- 4 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 .changeset/i18n-stragglers-4375-4376.md 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/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" From d0a42809b57cefa2b6bb9c4aaa7770cefa29874d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:47:14 +0000 Subject: [PATCH 4/4] test(plugin-list): byte-exact pin for the list search placeholder (#4375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pre-existing search assertion in this file matches /search/i, which matches 'Search...' and 'Search…' equally — so nothing here could see #4375 in either direction. Measured, not assumed: reverting the call site with only those assertions in place leaves the whole file green. Also pins that the trigger tooltip keeps the bare noun list.search, which is why the ellipsis could not simply be added to that key. --- .../src/__tests__/ListView.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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',