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
23 changes: 23 additions & 0 deletions .changeset/i18n-stragglers-4375-4376.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 10 additions & 7 deletions packages/fields/src/complex-widgets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
});
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1082,7 +1082,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder={t('common.search') + '...'}
placeholder={t('table.search')}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
Expand Down
226 changes: 226 additions & 0 deletions packages/i18n/src/__tests__/untranslated-identity-4376.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
// 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<string, unknown>)) {
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<NonLatin, Map<string, string>>;

/** `[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');
}
});
});
8 changes: 4 additions & 4 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ const ar = {
},
},
refresh: "تحديث",
loading: 'Loading records…',
loading: "جارٍ تحميل السجلات…",
recordCount: "{{count}} سجلات",
recordCountOne: "{{count}} سجل",
addRecord: "إضافة سجل",
Expand Down Expand Up @@ -775,8 +775,8 @@ const ar = {
readonlyTooltip: "عرض النظام — معرَّف في الكود، للقراءة فقط.",
},
designer: {
undo: 'Undo',
redo: 'Redo',
undo: "تراجع",
redo: "إعادة",
},
detail: {
editInlineHint: "انقر نقرًا مزدوجًا للتحرير",
Expand Down Expand Up @@ -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: "كود",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ const es = {
},
},
refresh: "Actualizar",
loading: 'Loading records…',
loading: "Cargando registros…",
recordCount: "{{count}} registros",
recordCountOne: "{{count}} registro",
addRecord: "Agregar registro",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading