From bac9cd4c57f0a7309db1815113e4dd2b037c4533 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:01:57 +0000 Subject: [PATCH 1/3] fix(i18n): ru and ar count labels read right at every count on the tab badge, reaction chip and comment thread (objectui#10242) The tab-count badge, the activity-feed reaction chip and the comment thread pick their plain count key at every count but one. In ru and ar that key held a single noun form, which is wrong at 2-4 and 21 in Russian and at 2 and 11+ in Arabic. The count-not-one half of five values becomes a count label (the objectui#10024 device), with the key set and call sites unchanged. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C --- .changeset/10242-ru-ar-count-labels.md | 30 ++ .../__tests__/countLabels.ruAr-10242.test.tsx | 264 ++++++++++++++++++ packages/i18n/src/locales/ar.ts | 13 +- packages/i18n/src/locales/ru.ts | 11 +- 4 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 .changeset/10242-ru-ar-count-labels.md create mode 100644 packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx diff --git a/.changeset/10242-ru-ar-count-labels.md b/.changeset/10242-ru-ar-count-labels.md new file mode 100644 index 0000000000..5520d57f06 --- /dev/null +++ b/.changeset/10242-ru-ar-count-labels.md @@ -0,0 +1,30 @@ +--- +'@object-ui/i18n': patch +--- + +Russian and Arabic now read correctly at every count on the tab-count badge's +accessible name, the activity-feed reaction chip and the comment thread's count +and reaction tooltip (objectui#10242). + +Each of these surfaces picks its `…One` key at exactly one and the plain key at +every other count. Two slots cover English. They do not cover Russian, which needs +three noun forms for whole numbers, or Arabic, which needs more. The plain key held +a single noun form, so it was wrong at some of the counts it serves. A Russian tab +badge read `2 элементов` at two (the language needs `2 элемента`) and +`21 элементов` at twenty-one (it needs `21 элемент`), and a Russian reaction chip +read `2 реакций` at two. The Arabic badge, comment count and reaction tooltip used +the plural form for three to ten, which is wrong at two and from eleven up. + +The count-not-one half of these five values is now a count label that reads +correctly at any number: + +- `ru` `common.itemCount`: `Элементов: N` +- `ru` `detail.reactionCount`: the emoji, then `Реакций: N` +- `ar` `common.itemCount`: `عدد العناصر: N` +- `ar` `collaboration.commentCount`: `عدد التعليقات: N` +- `ar` `collaboration.reactionCount`: `عدد التفاعلات: N` + +That is the device objectui#10024 used for `search.resultsCountPlural`, and the one +Russian's `collaboration.commentCount` and `collaboration.reactionCount` already +use. The singular halves, the key set, the call sites and the other eight packs are +unchanged. diff --git a/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx b/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx new file mode 100644 index 0000000000..66fa65c259 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx @@ -0,0 +1,264 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10242 — the sibling keys of objectui#10024. Three surfaces choose + * their key on `count === 1`: the `page:tabs` count badge's accessible name + * (`common.itemCount`/`itemCountOne`), the activity-feed reaction chip + * (`detail.reactionCount`/`reactionCountOne`) and the comment thread's header + * and reaction tooltip (`collaboration.commentCount`/`reactionCount` and their + * `…One` halves). Two slots fit `en`. They do not fit `ru` (one: 1, 21, 31…; + * few: 2-4, 22-24…; many: 0, 5-20…) or `ar` (zero, one, two, few 3-10, + * many 11-99, other 100-102…). + * + * What shipped before this card: the count-not-one half held one noun form. In + * `ru` that was the `many` form, so a badge read `2 элементов` at two and + * `21 элементов` at twenty-one, and a chip read `👍 2 реакций`. In `ar` it was + * the 3-10 plural, so the badge, the header and the tooltip were wrong at 2 and + * from 11 up. + * + * ## The repair: the objectui#10024 device, key by key + * + * The count-not-one half becomes a count label — a label, a colon, then the + * number — so no noun has to agree with the number. The `ru` values match the + * `ru` controls that already read right, `collaboration.commentCount` + * (`Комментариев: {{count}}`) and `collaboration.reactionCount` + * (`Реакций: {{count}}`). The `ar` values use the `عدد …: {{count}}` shape of + * `search.resultsCountPlural` and `calendar.a11y.dayCell`. The singular halves, + * the key set and the call sites are unchanged. ⛔ Not an i18next `_few` / + * `_two` family: `all-locales-key-parity.test.ts` holds every pack to `en`'s + * key set, so a `ru` `_few` is a key `en` lacks. + * + * ## Why this file sits beside the objectui#10024 pin + * + * It is the same defect on the neighbouring keys, pinned in the same shape + * (`SearchResultsPage.resultsCountPlural-10024.test.tsx`). `@object-ui/app-shell` + * is also the one package that depends on all three consumers — + * `@object-ui/components`, `@object-ui/plugin-detail` and + * `@object-ui/collaboration` — so one file can render each of them for real. + * + * ## What this file measures + * + * The strings a user reads, from a real render inside a real i18next instance + * booted in the session's language. `t` is not mocked. The reaction chip + * passes `count` as a number, so i18next's own plural lookup (`_few` before the + * bare key) is in the path. `en` and the `ru` comment thread are the controls: + * this card does not change them. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import React from 'react'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { SchemaRenderer } from '@object-ui/react'; +// Registers `page:tabs` (and every other renderer) at module scope, never in a +// hook — see object-ui/no-dynamic-import-in-test-hook. +import '@object-ui/components'; +import { ReactionPicker } from '@object-ui/plugin-detail'; +import { CommentThread, type Comment } from '@object-ui/collaboration'; + +type Lang = 'en' | 'ru' | 'ar'; + +afterEach(() => cleanup()); + +function boot(lang: Lang) { + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + // The instance really booted in the language under test; otherwise every + // string below would be `en` read through `fallbackLng`. + expect(i18n.language).toBe(lang); + return i18n; +} + +/** The `page:tabs` count badge's accessible name at `count`. */ +function tabBadge(lang: Lang, count: number): string { + const text = (content: string) => [{ type: 'element:text', properties: { content } }]; + const { container } = render( + + , + ); + const badges = container.querySelectorAll('[role="tab"] span[aria-label]'); + expect(badges, `${lang} at ${count}: tab badge not found exactly once`).toHaveLength(1); + expect(badges[0].textContent).toBe(String(count)); + const name = badges[0].getAttribute('aria-label') ?? ''; + cleanup(); + return name; +} + +/** The activity-feed reaction chip's accessible name at `count`. */ +function reactionChip(lang: Lang, count: number): string { + const { container } = render( + + + , + ); + // No `onToggleReaction`, so no add-reaction button: the chip is the only one. + const chips = container.querySelectorAll('button[aria-label]'); + expect(chips, `${lang} at ${count}: reaction chip not found exactly once`).toHaveLength(1); + const name = chips[0].getAttribute('aria-label') ?? ''; + cleanup(); + return name; +} + +const users = (n: number) => Array.from({ length: n }, (_, i) => `u${i}`); + +/** + * The comment thread at `count`: its header line with `count` comments, and + * the tooltip of a reaction that `count` users gave. + */ +function commentThread(lang: Lang, count: number): { header: string; tooltip: string } { + const comments: Comment[] = Array.from({ length: count }, (_, i) => ({ + id: `c${i}`, + author: { id: 'u0', name: 'Author' }, + content: 'Looks good.', + mentions: [], + createdAt: new Date().toISOString(), + reactions: i === 0 ? { '👍': users(count) } : undefined, + })); + const { container } = render( + + + , + ); + // The header's count sits in a leaf span that holds nothing else (the thread + // is unresolved, so no suffix follows it). Every other leaf span is a name, + // a timestamp or a comment body, and none of those carries a digit here. + const headers = Array.from(container.querySelectorAll('span')).filter( + (span) => span.children.length === 0 && (span.textContent ?? '').includes(String(count)), + ); + expect(headers, `${lang} at ${count}: header not found exactly once`).toHaveLength(1); + const chips = Array.from(container.querySelectorAll('button[title]')).filter( + (button) => button.textContent === `👍 ${count}`, + ); + expect(chips, `${lang} at ${count}: reaction chip not found exactly once`).toHaveLength(1); + const result = { + header: headers[0].textContent ?? '', + tooltip: chips[0].getAttribute('title') ?? '', + }; + cleanup(); + return result; +} + +/** One row per count: badge name, thread header, thread tooltip. */ +type Row = [count: number, badge: string, header: string, tooltip: string]; + +/** + * Every string this file expects. `1` is the untouched singular half. The + * other counts are the triage's representative numbers: `ru` 2 (few), 5 (many) + * and 21 (one); `ar` 2 (two), 3 (few) and 11 (many). + */ +const EXPECTED: Record = { + en: [ + [1, '1 item', '1 comment', '1 reaction'], + [2, '2 items', '2 comments', '2 reactions'], + ], + ru: [ + [1, '1 элемент', '1 комментарий', '1 реакция'], + [2, 'Элементов: 2', 'Комментариев: 2', 'Реакций: 2'], + [5, 'Элементов: 5', 'Комментариев: 5', 'Реакций: 5'], + [21, 'Элементов: 21', 'Комментариев: 21', 'Реакций: 21'], + ], + ar: [ + [1, '1 عنصر', '1 تعليق', '1 تفاعل'], + [2, 'عدد العناصر: 2', 'عدد التعليقات: 2', 'عدد التفاعلات: 2'], + [3, 'عدد العناصر: 3', 'عدد التعليقات: 3', 'عدد التفاعلات: 3'], + [11, 'عدد العناصر: 11', 'عدد التعليقات: 11', 'عدد التفاعلات: 11'], + ], +}; + +/** + * The reaction chip, `en` and `ru` only: the card's key list names `ru` + * `detail.reactionCount`, not the `ar` one. + */ +const CHIP: Record<'en' | 'ru', Array<[number, string]>> = { + en: [ + [1, '👍 1 reaction'], + [2, '👍 2 reactions'], + ], + ru: [ + [1, '👍 1 реакция'], + [2, '👍 Реакций: 2'], + [5, '👍 Реакций: 5'], + [21, '👍 Реакций: 21'], + ], +}; + +describe('count labels read correctly at every CLDR category in ru and ar (objectui#10242)', () => { + it('the representative counts reach the categories the count-not-one half serves', () => { + const ru = new Intl.PluralRules('ru'); + const ar = new Intl.PluralRules('ar'); + expect([2, 5, 21].map((n) => ru.select(n))).toEqual(['few', 'many', 'one']); + expect([2, 3, 11].map((n) => ar.select(n))).toEqual(['two', 'few', 'many']); + }); + + describe.each(['en', 'ru', 'ar'] as const)('%s', (lang) => { + it.each(EXPECTED[lang])('page:tabs badge at %i', (count, badge) => { + expect(tabBadge(lang, count)).toBe(badge); + }); + + it.each(EXPECTED[lang])('comment thread at %i', (count, _badge, header, tooltip) => { + expect(commentThread(lang, count)).toEqual({ header, tooltip }); + }); + }); + + describe.each(['en', 'ru'] as const)('%s', (lang) => { + it.each(CHIP[lang])('reaction chip at %i', (count, chip) => { + expect(reactionChip(lang, count)).toBe(chip); + }); + }); + + it('ru no longer binds the many-form noun to a few or one count', () => { + // The shipped defect, named as the strings a Russian user read. + expect(tabBadge('ru', 2)).not.toBe('2 элементов'); + expect(tabBadge('ru', 21)).not.toBe('21 элементов'); + expect(reactionChip('ru', 2)).not.toBe('👍 2 реакций'); + expect(reactionChip('ru', 21)).not.toBe('👍 21 реакций'); + }); + + it('ar no longer binds the 3-10 plural to a dual or an 11+ count', () => { + // The shipped defect, named as the strings an Arabic user read. + expect(tabBadge('ar', 2)).not.toBe('2 عناصر'); + expect(tabBadge('ar', 11)).not.toBe('11 عناصر'); + expect(commentThread('ar', 2)).not.toEqual({ header: '2 تعليقات', tooltip: '2 تفاعلات' }); + expect(commentThread('ar', 11).header).not.toBe('11 تعليقات'); + expect(commentThread('ar', 11).tooltip).not.toBe('11 تفاعلات'); + }); + + it('the count-not-one half is count-invariant: it ends in the number, after a colon', () => { + // The mechanism of the repair, stated on the pack values, so a later + // "natural-sounding" rewrite back to a number-then-noun form fails here + // with the reason attached: two slots cannot give that noun the right form. + const at = (lang: 'ru' | 'ar', dotted: string) => + dotted + .split('.') + .reduce((node, part) => (node as Record)?.[part], builtInLocales[lang]); + const KEYS: Record<'ru' | 'ar', string[]> = { + ru: ['common.itemCount', 'detail.reactionCount'], + ar: ['common.itemCount', 'collaboration.commentCount', 'collaboration.reactionCount'], + }; + for (const lang of ['ru', 'ar'] as const) { + for (const key of KEYS[lang]) { + const value = at(lang, key); + expect(value, `${lang} ${key}`).toMatch(/: \{\{count\}\}$/); + // …and the two halves are two different sentences. + expect(value, `${lang} ${key} equals its One half`).not.toBe(at(lang, `${key}One`)); + } + } + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 52431b07b1..64f927a97b 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -121,7 +121,11 @@ const ar = { closeChat: "إغلاق المحادثة", closePanel: "إغلاق اللوحة", resizeDrawer: "تغيير عرض اللوحة", - itemCount: "{{count}} عناصر", + // objectui#10242 — the tab-count badge switches keys only at exactly 1, so + // this half serves two (2), few (3-10), many (11-99) and other (100+), + // which need different noun forms. A count label («عدد …: {{count}}», as + // `search.resultsCountPlural` does) reads correctly at every one of them. + itemCount: "عدد العناصر: {{count}}", itemCountOne: "{{count}} عنصر", toggleSidebar: "تبديل الشريط الجانبي", package: "الحزمة", @@ -3639,7 +3643,10 @@ const ar = { removeSort: "إزالة الترتيب", }, collaboration: { - commentCount: "{{count}} تعليقات", + // objectui#10242 — `commentCount` and `reactionCount` below serve every + // count but 1 (zero, two, few, many, other), so each is a count label + // («عدد …: {{count}}») rather than one noun form that fits only 3-10. + commentCount: "عدد التعليقات: {{count}}", commentCountOne: "{{count}} تعليق", resolvedSuffix: " · تم الحل", sortComments: "ترتيب التعليقات", @@ -3652,7 +3659,7 @@ const ar = { hoursAgo: "قبل {{count}} ساعة", daysAgo: "قبل {{count}} يوم", edited: "(تم التعديل)", - reactionCount: "{{count}} تفاعلات", + reactionCount: "عدد التفاعلات: {{count}}", reactionCountOne: "{{count}} تفاعل", addThumbsUp: "إضافة إعجاب", reply: "رد", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 02e4749628..e7d8d5ee50 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -123,7 +123,11 @@ const ru = { closeChat: "Закрыть чат", closePanel: "Закрыть панель", resizeDrawer: "Изменить ширину панели", - itemCount: "{{count}} элементов", + // objectui#10242 — the tab-count badge switches keys only at exactly 1, so + // this half serves one (21, 31…), few (2-4, 22-24…) AND many (5-20…). No + // single `{{count}} ` form agrees with all three; a count label does, + // the same device as `detail.repeaterItemCount`. + itemCount: "Элементов: {{count}}", itemCountOne: "{{count}} элемент", toggleSidebar: "Переключить боковую панель", package: "Пакет", @@ -936,7 +940,10 @@ const ru = { addReaction: "Добавить реакцию", pageHeaderActions: "Действия в заголовке страницы", emojiPicker: "Выбор эмодзи", - reactionCount: "{{emoji}} {{count}} реакций", + // objectui#10242 — the chip picks this half at every count but 1, which + // spans one, few and many; a count label reads right at all three, the same + // device as `collaboration.reactionCount`. + reactionCount: "{{emoji}} Реакций: {{count}}", reactionCountOne: "{{emoji}} {{count}} реакция", recordDetail: "Сведения о записи", recordDetailWithLabel: "Сведения: {{label}}", From 6aa965ae71f7be57e0d1b3128e3854faad14a0d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:42:03 +0000 Subject: [PATCH 2/3] test(i18n): re-derive the common.itemCount premise for the ru and ar count labels (objectui#10242) The marketplace/preview namespace suite asserted, as a premise, that every pack's common.itemCount is the count, a space, then the unit noun. That pinned the exact ru and ar values this card turns into count labels. The other six packs keep the exact premise; ru and ar expect their label. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C --- .../marketplace-preview-namespace-3546.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx b/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx index b6a6965923..c466a113cc 100644 --- a/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx @@ -642,10 +642,16 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', ru: 'элементов', ar: 'عناصر', }; + // `ru` and `ar` state that noun inside a count label since objectui#10242, + // because their `common.itemCount` half serves every count but 1. + const ITEM_COUNT_LABEL: Partial> = { + ru: 'Элементов: {{count}}', + ar: 'عدد العناصر: {{count}}', + }; for (const [lang, unit] of Object.entries(UNIT) as [LocaleCode, string][]) { - // the premise, unchanged: that unit really is what common.itemCount uses + // the premise: that unit really is what common.itemCount uses expect(at(builtInLocales[lang], 'common.itemCount'), `${lang} common.itemCount`).toBe( - `{{count}} ${unit}`, + ITEM_COUNT_LABEL[lang] ?? `{{count}} ${unit}`, ); expect( (at(builtInLocales[lang], 'preview.history.items') as string).toLowerCase(), From f0645e02afc8f213f48f716aaced37f71a76b58c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:03:03 +0000 Subject: [PATCH 3/3] docs(changeset): claim Arabic only on the surfaces this change fixes (objectui#10242) The opening sentence claimed Arabic on the activity-feed reaction chip too, but ar detail.reactionCount is not one of the five values this change rewrites. Russian is now claimed on the badge and the reaction chip, Arabic on the badge and the comment thread. A later sentence that read as if every surface's plain key held a single noun form is scoped to the ones that did. The frontmatter is unchanged. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C --- .changeset/10242-ru-ar-count-labels.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/10242-ru-ar-count-labels.md b/.changeset/10242-ru-ar-count-labels.md index 5520d57f06..858891200d 100644 --- a/.changeset/10242-ru-ar-count-labels.md +++ b/.changeset/10242-ru-ar-count-labels.md @@ -2,14 +2,14 @@ '@object-ui/i18n': patch --- -Russian and Arabic now read correctly at every count on the tab-count badge's -accessible name, the activity-feed reaction chip and the comment thread's count -and reaction tooltip (objectui#10242). +Russian now reads correctly at every count on the tab-count badge's accessible +name and the activity-feed reaction chip, and Arabic on the badge and the comment +thread's count and reaction tooltip (objectui#10242). Each of these surfaces picks its `…One` key at exactly one and the plain key at every other count. Two slots cover English. They do not cover Russian, which needs -three noun forms for whole numbers, or Arabic, which needs more. The plain key held -a single noun form, so it was wrong at some of the counts it serves. A Russian tab +three noun forms for whole numbers, or Arabic, which needs more. Where the plain key +held a single noun form, it was wrong at some of the counts it serves. A Russian tab badge read `2 элементов` at two (the language needs `2 элемента`) and `21 элементов` at twenty-one (it needs `21 элемент`), and a Russian reaction chip read `2 реакций` at two. The Arabic badge, comment count and reaction tooltip used