Repository navigation
Two call sites build a search placeholder as t(key) + '...' — the ellipsis is concatenated in code, so it stays ASCII in all ten locales and cannot be translated away #4375
Description
Found while executing #3878 (ellipsis convergence on U+2026), on branch claude/issue-3878-i18n-copy-trio. Out of that card's scope — the dispatch scoped ListView.tsx to the LIST_DEFAULT_TRANSLATIONS duplicate-copy case, and that case does not apply here (see below) — so recorded separately.
The two lines
packages/plugin-list/src/ListView.tsx:2829
placeholder={t('list.search') + '...'}
packages/fields/src/widgets/LookupField.tsx:1085
placeholder={t('common.search') + '...'}In both, the translated value is the bare noun (list.search / common.search — 'Search' in en, '搜索' in zh, 'Pesquisar' in pt, …) and the ellipsis is a literal appended in code. Two consequences:
- It stays ASCII in every locale. [finding] en 包 32 个值用 ASCII "..." 结尾、113 个用 U+2026 "…" —— 同一屏上省略号两种字形,且九包各自跟着抄 #3878 converged all ten packs on the typographic ellipsis
…(U+2026), per the consistency pass on objectstack#6015. Neither placeholder is a pack value, so both were untouched: the list search popover and the lookup picker render搜索.../Pesquisar.../Search...with three ASCII full stops on screens where every other placeholder now ends in…. - No locale can opt out of the ellipsis at all. Whether a trailing ellipsis belongs on a placeholder is a per-language typographic call, and this spelling takes it away from the packs entirely.
argets a left-to-right ASCII run appended to a right-to-left string, which is the sharpest version of the problem.
How the second one was found — it costs a lap if you don't know it is there
LookupField.tsx:1085 was found the hard way, and that is worth recording. packages/fields/src/widgets/useFieldTranslation.ts carries 'table.search': 'Search...' in its no-provider defaults map, which #3878 converged to Search… along with the pack. Two tests in packages/fields/src/complex-widgets.test.tsx assert getByPlaceholderText('Search...') against a rendered LookupField, and it is entirely natural to read those as pinning that map's value — so they get "fixed" to Search… and go red, because the placeholder they actually pin comes from the concatenation on line 1085 and never moved.
So the two spellings are not interchangeable evidence: a Search... in a fields test may be pinning the defaults map (moves with the pack) or this concatenation (does not). #3878's branch carries a comment at that assertion naming line 1085 and this issue, so the next reader does not repeat the lap.
Why the #4294 duplicate-copy check did not catch either
LIST_DEFAULT_TRANSLATIONS in ListView.tsx is clean: it carries 'list.loading': 'Loading records…' and 'list.refreshing': 'Refreshing…', both already U+2026, and it holds no copy of any of the 35 keys #3878 converged. The defect here is a different shape — not a duplicated value, but a value assembled at the call site.
It is also invisible to every i18n gate for that reason. scripts/check-i18n-call-site-keys.mjs reads the t() call and its options object; a + '...' outside the call is not an inline defaultValue, so there is nothing for its byte-exact rule to compare. all-locales-key-parity and check-i18n-en-drift.mjs never see it either — the assembled string does not exist in any pack. #3878's new pin (packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts) scans pack values only and says so in its header.
Fix directions
A — give each placeholder its own pack key. table.search already means exactly this and is now 'Search…' in en, so both call sites can read it (or a new list.searchPlaceholder / lookup.searchPlaceholder if the two want to diverge later). Each pack then decides its own ellipsis, and the values come under the #3878 pin like everything else. Zero new keys if table.search is reused.
B — change the two literals to '…'. Two characters, fixes the glyph, leaves the concatenation and therefore leaves the ellipsis untranslatable and the RTL case unaddressed.
A is contract-first and is the one I would take; B is a band-aid on the visible half. Recording both rather than picking, since A may want a key-set decision.
Whichever lands must also move the two complex-widgets.test.tsx assertions (and any ListView equivalent) off Search... — they are pinned to the current, concatenated truth.
Anchor
Verified on origin/main at 2fea4d2fa. Related: #3878 (the convergence, and the … the rest of the screen now uses), #4294 (the duplicated-copy trap this is adjacent to but not an instance of).
Generated by Claude Code
Activity
[-]ListView builds the list-search placeholder as `t('list.search') + '...'` — the ellipsis is concatenated in code, so it stays ASCII in all ten locales and cannot be translated away[/-][+]Two call sites build a search placeholder as `t(key) + '...'` — the ellipsis is concatenated in code, so it stays ASCII in all ten locales and cannot be translated away[/+]yinlianghui commentedon Aug 11, 2026
CLAIM (pair) — PM seat
session_017Qqyix2QcnpUC9XeYVDzx3, dispatching one dev agent for #4375 + #4376 (adjacent post-#3878 stragglers, shared locale-pack surface — one PR avoids two branches racing on the packs), branchclaude/issue-4375-4376-i18n-stragglers, one PR withFixes #4375+Fixes #4376.Rulings riding on the dispatch (delegated authority, veto window open):
t(key) + '...'— the ellipsis is concatenated in code, so it stays ASCII in all ten locales and cannot be translated away #4375 — direction A (contract-first), as the card leans: the placeholder becomes a pack value; the code concatenation dies at both call sites. Reusetable.searchvs mint a purpose key is the implementer's call, decided by MEASURING the namespace conventions at each call site (what the surrounding hook already reads) and justified in the PR body — not by taste. New/changed values land in all ten packs, under [finding] en 包 32 个值用 ASCII "..." 结尾、113 个用 U+2026 "…" —— 同一屏上省略号两种字形,且九包各自跟着抄 #3878's U+2026 pin. The twocomplex-widgets.test.tsxassertions (and any ListView equivalent) move off the concatenated truth in the same change.list.loadingserves the raw English sentence "Loading records…" in eight of the nine packs (zh alone translated it) — plus 3 more prose values byte-identical to en #4376 — translate all four:list.loadingin all 8 identical packs,designer.undo/designer.redoin all 9 (the card's own evidence — every neighbouring designer action is translated and all five non-Latin languages have ordinary words; the identity reads as omission, not decision),appDesigner.snakeCaseHintin ko/ru/ar. Plus the gate the card records as an option — take it: byte-identity probe scoped to the five non-Latin packs (value === en[key] && /[A-Za-z]{4,}/.test(value)), with the ~14 legitimate identities as an explicit allowlist. This is the fourth card in the value-blind-spot family (console.objectView 的 2 个 key 在 8 个语言包里是英文原文,其中 systemViewReadonly 还是与 en 不一致的旧文案(全部门禁皆绿) #3582/view.readonlyTooltip 在 8 个语言包里译的是「duplicate to customize」这句已废弃语义 —— 因为它是真译文,任何「值是英文」判据都看不见它 #3625/[finding] 8 处 auth 调用点的内联 defaultValue 与 en 包值不一致 —— key 存在故兜底是死代码,三道 i18n 门禁按设计都看不见这一类 #3810/list.loadingserves the raw English sentence "Loading records…" in eight of the nine packs (zh alone translated it) — plus 3 more prose values byte-identical to en #4376); a 14-entry allowlist is cheap against a recurring class. Guard-class → pre-fix red run required (The report config panel is titled "Title" and the empty state says "Object Not Found" — call sites asking for a key whose value is not the copy for that slot #4118).#4377 (designer's own en/zh table) is NOT in this batch — its file is in app-shell, held by #4040-T5; it pools until that lands.
Generated by Claude Code
fix(i18n): search placeholders become pack values, and four English-s…