As a user I want count-based messages to be grammatically correct so the UI reads naturally in every supported language #287
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Pluralization is currently handled three different ways in
frontend/messages/{de,en,es}.json:_one/_manypairs — e.g.person_card_doc_count_one("1 doc.") /person_card_doc_count_many("{count} docs."). Selection happens in components via a ternary.(s)/(n)suffix hacks — e.g. EShace {count} minuto(s),{count} documento(s) creado(s),{count} página(s) omitida(s).count === 1— e.g.{count} documentosrenders as "1 documentos".The result is inconsistent, duplicated, and a steady source of small rendering bugs.
Proposal
Adopt Paraglide's message variants for every count-bearing message.
Variants use CLDR plural rules, live entirely in the translation files, and are type-safe at build time. Call sites go from
count === 1 ? m.foo_one() : m.foo_many({ count })to a singlem.foo({ count }).Scope
de.json,en.json,es.jsonto variant syntax — every_one/_manypair, every(s)/(n)message, every plural-only message that takes a{count}._one/_manypairs after all call sites are migrated.Known affected key groups (non-exhaustive — grep
{count}to get the full list):docs_result_count,docs_empty_for_termperson_card_doc_count_*,person_show_more,persons_stats_persons_many,persons_stats_documents_manyupload_success,enrich_needs_metadata_count,enrich_progressocr_status_creating_blocks,ocr_status_done_blocks,ocr_status_done_skippedtranscription_status_sections,transcription_reviewed_countcomment_time_minutes/_hours/_daysadmin_groups_permission_count,admin_system_backfill_success,admin_system_import_status_doneadmin_tag_children_more,admin_tag_merge_preview_docs,admin_tag_merge_preview_children,admin_tag_delete_subtree_warntopbar_overflow_more,topbar_overflow_show,doc_details_more_receiversconv_letters_count,notification_bell_unread_labelmission_control_ready_subtitle,mission_control_weekly_pulse,dashboard_blocksAcceptance criteria
_one/_manykey pairs in any messages file.(s)/(n)suffix hacks in any translation."1 documentos"-style output — spot-check search results, upload success, person card counts, OCR status, admin stats.count === 0,count === 1,count === 2all render correctly for every migrated key in de / en / es.npm run check,npm run lint, andnpm run testpass.Non-goals
one/other).Notes / drawbacks
one/otherforms), but the(s)hacks are indefensible and variants kill them for free.🏛️ Markus Keller — Senior Application Architect
Observations
plugin-message-format@4is already in place (frontend/project.inlang/settings.json) — variants compile at build time into typed functions. No runtime cost, no new dependency.de/en/esto migrate (32 per locale). That's big enough to need discipline; small enough to not warrant staged infra.ocr_status_done_skipped: "{count} Blöcke erstellt, {skipped} Seite(n) übersprungen". Variant syntax supports multi-selector matches — don't collapse this to a single-selector message or you'll lose the grammar win forskipped.Recommendations
{count}as the selector name across all variant messages. Any message with a countable noun uses{count}— no{n}, no{amount}, no{total}. One convention, easier to grep, easier for future translators.ocr_status_done_skipped), use a two-dimensional variant and add a frontmatter comment or ADR note if you find yourself introducing a third. Two dimensions is fine; three is a design smell that probably wants sentence composition._one/_manykeys in the same commit as the call-site migration, never in a separate cleanup commit. Leaving dead keys "for later" guarantees drift between the three locale files.Open Decisions
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
frontend/src/routes/persons/PersonsStatsBar.svelte:13-19andfrontend/src/routes/persons/+page.svelte:162-164. The rest are plural-only messages called with{ count }and silently rendering "1 Dokumente" / "1 documentos" style for count=1. Those are the quiet-failure sites; the ternaries are the loud ones.PersonsStatsBar.svelteis a special case: its labels are called without a count argument (just the word "Personen" / "Person"). After migration, the component needs to pass{ count: totalPersons }into the variant call — not "total people labeled", but "pluralize this noun using the count I'm already displaying next to it". Don't forget the prop.hace {count} minuto(s)→ count=0 Spanish uses plural ("hace 0 minutos"), which CLDRotheralready handles correctly. Variants fix this for free.Recommendations
count=0,count=1,count=2, in all three locales. Run it red before editing the JSON, green after. Seven lines per test, fast feedback, proves the migration actually works.person_card_doc_count_*+persons_stats_*+ their test file. Never mix two areas in one commit — violates the atomic-commit rule, also makes revert painful if one area regresses._one/_manykeys in the same commit as the call-site + variant edit. Do not leave a "cleanup" commit for later — dead keys in locale files rot.count === 1 ? m.foo_one() : m.foo({ count }), that means the variant isn't wired up. The whole point is a single call.ocr_status_done_skipped(two count variables), write the multi-selector variant by hand first, test it with 2×2 combinations (1/1, 1/n, n/1, n/n) — this is where variant authoring goes wrong and one failing test will catch it.Open Decisions
🔒 Nora Steiner — Application Security Engineer
Observations
countvalue — noinnerHTML, noeval, no runtime template parsing. Zero new injection surface.{count}values are integers computed from backend data or client state.error_*keys) are not in scope of this migration — they don't carry counts — so no impact on thegetErrorMessage()mapping layer.Recommendations
Open Decisions
(none)
🧪 Sara Holt — Senior QA Engineer
Observations
Recommendations
frontend/src/lib/paraglide/variants.test.ts:count=0,count=1,count=2acrossde,en,es,/^1 \w+en\b/,/^1 \w+os\b/,/^1 \w+s\b/,\(s\),\(n\),\(es\).This runs in milliseconds, stays in the suite forever, catches anyone reintroducing the pattern in a future locale addition.
ocr_status_done_skipped— render all four combinations (1/1, 1/many, many/1, many/many) in each locale. This is the single highest-risk key in the migration.npm run testmust pass on every commit. The test harness above should be added in the first commit with only the keys being migrated in that commit registered. Grows incrementally with each subsequent commit.npm run checkalone. TypeScript happily accepts a variant call signature that's grammatically broken at runtime — type-checking catches the call shape, not the rendered output.PersonsStatsBar.sveltecall-site change — its labels (Person/Personen) have no{count}placeholder in the rendered string, only act as a plural selector. Easy to misimplement asm.persons_stats_label_persons()(no args) vsm.persons_stats_label_persons({ count }). Unit-test the component.Open Decisions
(none — systematic variant regression test is the right call here; it's a $1 investment for permanent protection.)
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
(s)/(n)literally announced by VoiceOver as "paren-s" / "paren-n" is a real accessibility hit that variants remove.otherrule covers count=0 by default: "0 Dokumente" / "0 documents" / "0 documentos" are all grammatically correct. But grammatically-correct is not the same as good empty-state copy.Recommendations
enrich_needs_metadata_count,admin_tag_delete_subtree_warn,ocr_status_done_skipped. Plural forms in German and Spanish can be a character or two longer than the_manyvariant was — minor, but worth a look.persons_stats_label_persons,persons_stats_label_documents) still render the UPPERCASE typographic style after the ternary is gone. That's a styling check onPersonsStatsBar.svelte, not a translation concern, but easy to overlook during refactor.Open Decisions
⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
paraglideVitePlugininvite.config.ts) — no new pipeline steps, no new env vars, no container changes.npm run check,npm run lint,npm run test) already cover the work. Nothing to add todocker-compose.ci.ymlor Gitea Actions.package.json— variants have been stable in this major since 2.0, no upgrade risk for the refactor.Recommendations
@inlang/paraglide-jsgets a minor bump, diff the generatedsrc/lib/paraglide/messages.jsoutput as part of the review, because variant compilation semantics are the surface most likely to shift. Non-blocking for this issue.Open Decisions
(none)
🗳️ Decision Queue — Action Required
1 decision needs your input before implementation starts.
UX
(Raised by: Leonie)
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Summary of UX-scoped discussion. All items resolved.
Resolved
Zero-case empty-state copy — grammatically-correct "0 Dokumente" / "0 documents" / "0 documentos" stays in scope for this migration as the correct rendering of count-zero stats (e.g.
PersonsStatsBar, counters, status chips). Tailored empty-state messages for search-no-results and similar contexts become a separate follow-up issue with per-component UI branching. Scope creep kept out of this refactor.German "weitere" adjective morphology — the four affected keys (
person_show_more,topbar_overflow_more,doc_details_more_receivers,admin_tag_children_more) migrate as plural-only variants using only theotherform. "+1 weitere" is a deliberate tradeoff: grammatically incorrect in isolation, but the "+N weitere" chrome-text pattern is visually more valuable than gender-agreed singular forms in space-constrained UI. Recorded here so future-us knows it was a choice, not an oversight.320px overflow verification — added to acceptance criteria. The following five keys must be verified at 320px viewport in
de / en / espost-migration (no overflow, no truncation, no layout-breaking wrap):enrich_needs_metadata_countadmin_tag_delete_subtree_warnocr_status_done_skippedmission_control_ready_subtitleadmin_system_import_status_doneUPPERCASE noun-only labels —
persons_stats_label_persons/persons_stats_label_documentsand similar stay in natural case in the translation files ("Person" / "Personen"). Typographicuppercasestyling stays on the wrapper span in the Svelte component. Typography is not an i18n concern and must not leak into locale files.Overall read
Grammar fix first, empty-state polish later. The boundary is clean: this migration owns plural correctness, a follow-up issue owns empty-state copy. No blockers from my side.