diff --git a/.changeset/10242-ru-ar-count-labels.md b/.changeset/10242-ru-ar-count-labels.md
new file mode 100644
index 0000000000..858891200d
--- /dev/null
+++ b/.changeset/10242-ru-ar-count-labels.md
@@ -0,0 +1,30 @@
+---
+'@object-ui/i18n': patch
+---
+
+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. 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
+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/__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(),
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}}",