fix(i18n): search placeholders become pack values, and four English-serving values get translated (#4375 #4376) - #4391
Merged
Conversation
…tin 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.
#4376) 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.
…atenations (#4375) 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.
) 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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
Collaborator
Author
|
ACCEPT — PM 复核 (session
Flipping ready + arming auto-merge. Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 12, 2026 01:08
This was referenced Aug 12, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4375
Fixes #4376
Two post-#3878 i18n stragglers on one shared surface (
packages/i18n/src/locales/*.ts), paired so two branches do not race on the ten packs. Both are value-domain defects — every key name, key set and placeholder shape was already correct, which is exactly why all three script gates were green on both.1. The two search placeholders become pack values (#4375)
Ruling applied: direction A (contract-first). The
t(key) + '...'concatenation dies at both call sites.The key choice: reuse
table.search, mint nothing — measured, not tasteThe card left "reuse
table.searchvs mintlist.searchPlaceholder/lookup.searchPlaceholder" to the implementer, to be decided by measuring the namespace conventions at each call site. Four measurements, all onorigin/mainat3e19fe78a:(a)
table.searchis already THE search-input placeholder key in this repo. It is read as aplaceholder=at three sites and is read nowhere else:packages/components/src/renderers/complex/data-table.tsx:1593placeholder={t('table.search')}packages/fields/src/widgets/RecordPickerDialog.tsx:1041placeholder={t('table.search')}packages/fields/src/widgets/PeoplePicker.tsx:417placeholder={t('table.search')}Its
envalue isSearch…— byte-for-byte the string both broken call sites were assembling. Two of those three sites are inpackages/fields, reading throughuseFieldTranslation— the same hookLookupFielduses, andRecordPickerDialog/PeoplePickerare its two nearest siblings in the same directory and the same control class (a record-picker popover with a filter input at the top). Reuse atLookupFieldis not a cross-namespace reach; it converges the one widget in that family that was doing something else.(b) The two keys the call sites used are bare NOUNS, and one of them must stay that way.
list.search=Search,common.search=Search— no ellipsis, by design.list.searchis also read three lines up as the trigger button's tooltip:So "just put the ellipsis in
list.search" was never available — it would have put one on that tooltip too. A pin inListView.test.tsxnow asserts both halves (placeholderSearch…, tooltipSearch) so the two keys cannot be collapsed later by accident.(c) The no-provider defaults table already carries the key at one site, and cross-namespace entries are the norm at both.
table.search?FIELD_DEFAULTS(useFieldTranslation.ts)'table.search': 'Search…'table.*,common.*,capability.*,fields.*LIST_DEFAULT_TRANSLATIONS(ListView.tsx)table.rowsPerPage,grid.toolbar.*,detail.recordDetail*So
LookupFieldneeded no map change at all, andListViewneeded one entry, byte-identical toenper that map's docblock contract. The borrow also has a precedent in that same map, with its rationale written down — thedetail.recordDetailentry says it is borrowed rather than minted aslist.recordDetailbecause "one heading on one control should not get two translations that can drift apart." That is the identical argument, so the new entry cites it rather than inventing a second convention.(d) The RTL half of the card is fixed for free, and only by (a). #4375 names
aras the sharpest case:t(…) + '...'appended an LTR ASCII run to the end of a right-to-left string. As a pack value the ellipsis is thearpack's own (table.search=بحث…), so the Unicode bidi algorithm resolves it as a neutral adjacent to the RTL run and it lands at the logical end. It also restores the thing the card said the concatenation "takes away from the packs entirely":arcan now drop the ellipsis by editing one pack value.Weighed against minting. Minting
list.searchPlaceholder+lookup.searchPlaceholdercosts 20 new values across ten packs, each one authored from scratch — 20 new chances to get the glyph or the register wrong — to buy a divergence nobody has asked for; all four surfaces mean "type here to filter the records shown." The values reused here are already translated, already reviewed, already under #3878's U+2026 pin in all ten packs. If the two ever do want to diverge, minting then is cheap and reversible; minting now is a speculative vocabulary surface with no measured pull. Zero new keys.Test pins moved off the concatenated truth
packages/fields/src/complex-widgets.test.tsx:200and:469assertedgetByPlaceholderText('Search...'). #3878's branch left a comment at the first one namingLookupField.tsx:1085and this issue, and saying "when that lands, this moves" — honored: both move toSearch…, and the comment is rewritten to record where the string now comes from (a pack value viauseFieldTranslation) so the next reader is not sent back down the same path.Swept for a
ListViewequivalent: there is none, and that is itself a finding. EveryListView.test.tsxsearch assertion matchesgetByPlaceholderText(/search/i), which matchesSearch...andSearch…— so no existing test could see this change in either direction. A byte-exact pin is added rather than assumed (see the reverse verification below, where its absence is measured rather than argued).2. Four values the packs were serving in English (#4376)
Ruling applied: translate all four, and take the gate.
Per-pack translations — 30 values
Each was written by reading that pack's neighbouring keys first, never by translating the English in isolation.
list.loading—en=Loading records….zhhad already translated it; the other eight served the English sentence verbatim. Each pack's own loading-verb shape and its own established word for "records" (taken fromlist.recordCount/list.noMatches/lookup.recordCount):レコードを読み込み中…grid.loading=グリッドを読み込み中…레코드 로딩 중…grid.loading=그리드 로딩 중…Datensätze werden geladen…grid.loading=Raster wird geladen…(plural subject →werden)Chargement des enregistrements…chart.loading=Chargement du graphique…Cargando registros…grid.loading=Cargando cuadrícula…Carregando registros…grid.loading=Carregando grade…Загрузка записей…grid.loading=Загрузка таблицы…(genitive)جارٍ تحميل السجلات…list.refreshing=جارٍ التحديث…The
archoice is worth stating: the pack spells the progressive two ways (جاريingrid/chart/common,جارٍinhome/dashboard). The sibling in the same namespace,list.refreshing, usesجارٍ, so this follows the namespace rather than the global majority — these two render on the same list view, one after the other.designer.undo/designer.redo— English in all nine packs. The card flagged these as "the arguable pair" (some product vocabularies keep them English). They are not arguable here, and the measurement settles it rather than my taste: this repo has already made the identical call three times, and the three families agree byte-for-byte in all nine packs.undoredo撤销重做appDesigner.*,gantt.toolbar.*,workflow.*元に戻すやり直す실행 취소다시 실행RückgängigWiederholenAnnulerRétablirDeshacerRehacerDesfazerRefazerОтменитьПовторитьتراجعإعادةSo no new vocabulary was invented:
designer.*is copied fromappDesigner.undo/redo,gantt.toolbar.undo/redoandworkflow.undo/redo, which are unanimous. (grid.bulk.undodeliberately diverges inja/ko—取り消す/되돌리기— because it undoes a bulk data operation, not an edit-history step. Not copied.)appDesigner.snakeCaseHint—en=Must be snake_case (e.g. my_app).snake_caseandmy_appare identifiers and stay literal; only the prose around them is translated. Each pack's "e.g." convention is taken fromappDesigner.navIconPlaceholder, the sibling in the same namespace that already has a parenthetical example:navIconPlaceholder)snake_case 형식이어야 합니다 (예: my_app)아이콘 이름 (예: Users)Должно быть в формате snake_case (например, my_app)Имя значка (например, Users)يجب أن يكون بصيغة snake_case (مثل my_app)اسم الأيقونة (مثل Users)Deve ser snake_case (ex. my_app)Nome do ícone (ex. Users)Deviation from the ruling's letter:
ptis a fourth untranslated pack on this keyThe ruling says ko/ru/ar, because the card's table says ko/ru/ar. Re-measuring found
ptis byte-identical toenon this key too — and the card's own text explains why it could not see that: its probe is scoped to the five non-Latin packs, and it warns in the same paragraph that the probe "is not sound forde/fr/es/pt."Here the ambiguity the scoping guards against does not exist:
de,frandesall translated this sentence, soptalone holding a full English sentence with a parenthetical example is an omission by the same evidence the card used for the other three. Translating ko/ru/ar while knowingly leavingptEnglish would have shipped a defect I had already found, so it is fixed and declared here rather than smuggled or dropped. The new gate does not and cannot cover it — it is non-Latin-scoped by ruling — so this one is a hand fix, and the gate's header says so explicitly rather than letting its green imply otherwise.The gate (#4118 guard-class: pre-fix RED required)
New:
packages/i18n/src/__tests__/untranslated-identity-4376.test.ts.Home. A dedicated pin file next to
ellipsis-glyph-3878.test.ts, not an addition toall-locales-key-parity.test.ts. That file's own header states its boundary — it owns "key sets and placeholder shape, and nothing about what a value SAYS" — so putting a value-domain probe inside it would contradict the boundary it documents. The value-domain pins in this package (ellipsis-glyph-3878,de-quote-pairing-3876,objectView-value-language-3582,viewReadonlyTooltip-semantics-3625) each live in their own file; this follows that convention. It is pack-intrinsic: it readsbuiltInLocalesand nothing else.Rule.
value === en[key] && /[A-Za-z]{4,}/.test(value), overzh/ja/ko/ru/aronly, minus an explicit allowlist. Not extended tode/fr/es/pt, per the ruling and the card's reasoning — identity is genuine there often enough that the allowlist would grow without bound. The{4,}floor keeps acronyms and units (URL,API,ID,CSV,v1) out of the report without an allowlist entry each.Pre-fix RED, run against unmodified packs before any translation landed (captured to a file, not piped through
head) — it names exactly the four defect keys, 17 pack/key pairs, and nothing else:The third test (
keeps the allowlist honest) passed on that same pre-fix run, which is the evidence that the 22 allowlist entries were each a live identity and not padding.Allowlist — 22 keys, re-measured, each with a one-line reason. The card estimated "~14"; that was a count of distinct values, and the probe reports keys. The census over
origin/mainat3e19fe78areturns 26 matching keys: 4 defects + 22 legitimate. Three of the 22 are not enumerated in the card's prose and were found by re-measuring:auth.setup.emailLabel,collaboration.userStatusTitle,organization.settings.logoLabel.layout.metadata.jsonBadgeJSONworkflow.webhookWebhookworkflow.webhookEventWebhookpublicForm.poweredByPowered by ObjectStackjakeeps the wordmark lineconnectAgent.apiKey.badgeheadlessmarketplace.pricing.freemiumFreemiumrukeeps the loanwordconsole.settingsHub.betaBetazhconsole.settingsHub.categories.BetaBetaappDesigner.logoUrlLogo URLLogo+ theURLacronym, used as-isappDesigner.faviconUrlFavicon URLFaviconhas no settled translation hereorganization.settings.logoLabelLogozh's own vocabulary — its whole block saysLogo 链接,Logo 已上传…auth.setup.emailLabelEmailruloanword — that block is otherwise fully localized (ООО Пример,name@example.ru), so it is a choice, not an omissionauth.login.emailPlaceholdername@example.comauth.register.emailPlaceholdername@example.comauth.forgotPassword.emailPlaceholdername@example.comauth.setup.emailPlaceholdername@example.comrulocalizes it toname@example.ru)auth.setup.orgNamePlaceholderAcme Inc.rulocalizes it,zh/jakeep itgrid.import.templateFileName{{object}}-import-templatefields.image.counter{{current}} / {{total}}marketplace.versionBadgev{{version}}appManagement.toast.bulkFailureEntry{{name}} ({{reason}})collaboration.userStatusTitle{{name}} ({{status}})The allowlist is keyed by key, not key+lang — looser (an entry excuses the identity in all five packs) but it does not churn when another pack legitimately adopts the same proper noun. The looseness is paid for by a second assertion,
keeps the allowlist honest, which fails on any entry that is no longer a live identity anywhere or names a keyendoes not define — so the list cannot quietly accumulate cover for keys it no longer describes. A third assertion pins the four repaired keys as non-empty and non-English, because the scan alone would go green if a value were simply deleted (green because nothing is produced is not the same fact as green because the copy is right — the same complementellipsis-glyph-3878.test.tscarries, for the same reason).Verification
pnpm exec vitest run packages/i18n/ packages/plugin-list/ packages/fields/ --maxWorkers=2node scripts/check-i18n-call-site-keys.mjsnode scripts/check-i18n-en-drift.mjsnode scripts/check-control-bytes.mjsnode scripts/check-changeset-presence.mjsnode scripts/check-changeset-no-major.mjstype-check(tsc --noEmitandtsc -p tsconfig.test.json) fori18n,plugin-list,fieldseslinton the touched filesno-explicit-anywarnings, none introduced)The drift gate's verdict is
0 keys added, not the additive verdict the dispatch anticipated — that is the direct consequence of the reuse decision in §1(a): no key was minted and noenvalue moved, so the gate has no event to report. The translations are pack-side only, which this gate is blind to by design; that blindness is precisely what #4376 exists to close, and the new pin is what closes it.CJK / Cyrillic / Arabic values are not control bytes —
check-control-bytesscans control bytes and is green. The touched files were additionally self-scanned withgrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]': no matches.Reverse verification (commit-then-revert, never
git stash)1. Revert
LookupField.tsxto the concatenation → the two moved assertions go red, in the predicted direction.Exactly the two that moved; the other 39 in the file stay green, so the revert isolates them rather than sweeping the file.
2. Revert
ListView.tsx→ the predicted direction here was different, and it was predicted before the run. The pre-existing assertions all usegetByPlaceholderText(/search/i), which matchesSearch...andSearch…equally — so no pre-existing test can go red on this call site in either direction. That is the null result, and it is the reason the byte-exact pin above was added rather than assumed. With the pin in place the same revert produces the red, and the sibling/search/itests stay green in the same run — both facts from one measurement:One red — the new pin. 147 green, and those 147 include every pre-existing
/search/isearch assertion in the file: the null result, measured rather than argued.3. Delete one new translation → the gate names that key. Reverting
ru'slist.loadingto the English value:Both the scan and its complement name the key, and nothing else moves.
All three reverts were taken with
git checkout/ an in-worktree edit and restored from the branch.git stashwas never used (objectui#3430 — the stash stack lives in the common.gitand is shared across worktrees, so the isolation this worktree gives does not extend to it).Run under
flock /tmp/os-heavy-verify.lock(build and DOM suites), which was contended for most of this task — the queue waits are the lock working, not a hang. Reverse verification 3 is a node-environment suite (one file, ~1.5s) and was run outside the lock with 13.6 GB available, which is declared here rather than left to be inferred.origin/mainmoved during the task (#4379, #4382 landed). Merged, not rebased, and no force push; neither commit touchespackages/i18n,packages/plugin-listorpackages/fields, and the full run above is on the merged tree.Scope
Held to
packages/i18n/src/locales/*.ts,packages/i18n/src/__tests__/**,packages/plugin-list/**,packages/fields/**and the changeset. Not touched, per the in-flight surfaces named at dispatch:packages/core/**,packages/app-shell/**(#4040-T5 — which is why #4377 is not fixed here even though it is the same glyph family),scripts/check-type-check-coverage.mjs,packages/plugin-dashboard/**,packages/plugin-report/**(#4330),packages/plugin-gantt/**,packages/plugin-grid/**(#4370), workflow/script comment files andcontent/docs/**(#4381).Left alone deliberately, not filed as twins: the hardcoded key-less placeholders in
packages/components/src/custom/combobox.tsx,packages/runner/src/LayoutRenderer.tsxandpackages/plugin-designer/src/__tests__/__mocks__/plugin-grid.tsxstill spellSearch..., but they read no key at all — instances of the open hardcoded-English family (#4028 and siblings), as #4378 already recorded.Changeset:
.changeset/i18n-stragglers-4375-4376.md, patch for the three released packages the presence gate names (nevermajor, per the version-alignment rule).Generated by Claude Code