diff --git a/.changeset/timeline-gantt-bucket-vocabulary-4520.md b/.changeset/timeline-gantt-bucket-vocabulary-4520.md
new file mode 100644
index 000000000..3adbbd7a5
--- /dev/null
+++ b/.changeset/timeline-gantt-bucket-vocabulary-4520.md
@@ -0,0 +1,16 @@
+---
+'@object-ui/plugin-timeline': minor
+'@object-ui/i18n': minor
+---
+
+The timeline's gantt bucket labels and its row-label default speak the session language
+
+objectui#4513 routed every `Intl` call in the timeline renderer through `useDisplayLocale()`, so a Chinese session renders `2026年8月` on the month axis and `2026年8月11日` on item dates. Three sibling strings in the same renderer never went through `Intl` at all and stayed English on that same Chinese axis: the `week` header (`Week 1`), the `quarter` header (`Q3 2026`), and the gantt row-label column default (`Items`). The half-fixed state was the visible one — a Chinese date axis with English bucket labels beside it.
+
+They are a translation concern rather than a locale-resolver one, and that distinction is the fix: a locale TAG formats a date, only a TRANSLATION spells a word. All three now resolve through the package's existing channel — `useTimelineTranslation` / `TIMELINE_DEFAULT_TRANSLATIONS`, the `createSafeTranslation` factory `ObjectTimeline` already uses for `timeline.bucket.*` — under three new keys carried by all ten locale packs: `timeline.scale.week`, `timeline.scale.quarter`, `timeline.gantt.rowLabel`.
+
+The week number and the quarter/year ride the channel's own `{{hole}}` parameters rather than being concatenated, because the word order belongs to the translation: Chinese puts the year first (`2026年第3季度`), which no `Q${q} ${year}` template can produce at all. Only the row-label DEFAULT moved — an author who writes `rowLabel` still supplies their own string, and the `year` scale stays a bare `String(getFullYear())` with no vocabulary in it to translate.
+
+English output is byte-identical to the retired literals: the `en` pack values are the same two templates the code used to interpolate by hand. `generateTimeScaleHeaders` is a pure exported function and cannot host a hook, so the translate fn is threaded in as an optional fifth parameter on the seam #4513 opened for `locale`, defaulting to the package's own defaults table — the same lookup the channel serves with no `I18nProvider` mounted. Existing three- and four-argument call sites are unaffected.
+
+One consequence is worth stating because it looks like a bug and is not: dates and vocabulary resolve through different channels on purpose. `useDisplayLocale()` puts the tenant's regional default first (how this organization writes dates), while `t` follows the UI language (what this user reads). A tenant configured `en` whose user reads Chinese chrome therefore sees `Aug 2026` beside `第 1 周` — the same split `timeline.bucket.*` has always had.
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 538b90254..d981fd3c5 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -643,6 +643,13 @@ const ar = {
noDate: "بدون تاريخ",
unassigned: "غير معين",
},
+ scale: {
+ week: "الأسبوع {{n}}",
+ quarter: "الربع {{quarter}} {{year}}",
+ },
+ gantt: {
+ rowLabel: "العناصر",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index e352fcba9..5b4f89d59 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -639,6 +639,13 @@ const de = {
noDate: "Kein Datum",
unassigned: "Nicht zugewiesen",
},
+ scale: {
+ week: "Woche {{n}}",
+ quarter: "Q{{quarter}} {{year}}",
+ },
+ gantt: {
+ rowLabel: "Elemente",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 13a8b83a1..5ced11f9c 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -688,6 +688,13 @@ const en = {
noDate: 'No date',
unassigned: 'Unassigned',
},
+ scale: {
+ week: 'Week {{n}}',
+ quarter: 'Q{{quarter}} {{year}}',
+ },
+ gantt: {
+ rowLabel: 'Items',
+ },
},
gantt: {
column: {
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index 684234783..ac17a150e 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -643,6 +643,13 @@ const es = {
noDate: "Sin fecha",
unassigned: "Sin asignar",
},
+ scale: {
+ week: "Semana {{n}}",
+ quarter: "T{{quarter}} {{year}}",
+ },
+ gantt: {
+ rowLabel: "Elementos",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index ec23eb7f5..c5dc6f608 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -639,6 +639,13 @@ const fr = {
noDate: "Sans date",
unassigned: "Non attribué",
},
+ scale: {
+ week: "Semaine {{n}}",
+ quarter: "T{{quarter}} {{year}}",
+ },
+ gantt: {
+ rowLabel: "Éléments",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 52f280d7e..607dfe800 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -639,6 +639,13 @@ const ja = {
noDate: "日付なし",
unassigned: "未割当",
},
+ scale: {
+ week: "第{{n}}週",
+ quarter: "{{year}}年第{{quarter}}四半期",
+ },
+ gantt: {
+ rowLabel: "項目",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index fd68981f3..f34f332a5 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -639,6 +639,13 @@ const ko = {
noDate: "날짜 없음",
unassigned: "미할당",
},
+ scale: {
+ week: "{{n}}주차",
+ quarter: "{{year}}년 {{quarter}}분기",
+ },
+ gantt: {
+ rowLabel: "항목",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 54884d96f..d18848c6b 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -638,6 +638,13 @@ const pt = {
noDate: "Sem data",
unassigned: "Não atribuído",
},
+ scale: {
+ week: "Semana {{n}}",
+ quarter: "T{{quarter}} {{year}}",
+ },
+ gantt: {
+ rowLabel: "Itens",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index c8fa5fb2d..3835b56f5 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -645,6 +645,13 @@ const ru = {
noDate: "Без даты",
unassigned: "Не назначено",
},
+ scale: {
+ week: "Неделя {{n}}",
+ quarter: "{{quarter}} кв. {{year}}",
+ },
+ gantt: {
+ rowLabel: "Элементы",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 1ba9dda48..14ccc16da 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -660,6 +660,13 @@ const zh = {
noDate: '无日期',
unassigned: '未分配',
},
+ scale: {
+ week: '第 {{n}} 周',
+ quarter: '{{year}}年第{{quarter}}季度',
+ },
+ gantt: {
+ rowLabel: '条目',
+ },
},
gantt: {
column: {
diff --git a/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx
index da9288892..70bd6635e 100644
--- a/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx
+++ b/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx
@@ -39,10 +39,13 @@
* half: `'en'` and the retired `'en-US'` produce byte-identical output at all
* five sites, so English rendering must not move by one character.
*
- * The locale-free headers (`Week n`, `Qn YYYY`, `YYYY`) and the non-date
+ * The locale-free headers (the week / quarter / year buckets) and the non-date
* rendering (titles, descriptions, row labels) are likewise green on both
* sides: they never went through `Intl`, and this file pins that the change
- * did not disturb them.
+ * did not disturb them. Their two English spellings were later routed through
+ * the package's TRANSLATE channel by objectui#4520 — a different channel from
+ * the one this card owns — so those two expected strings have moved once,
+ * deliberately; see the case itself.
*
* The provider-LESS last resort is deliberately not measured here — see
* `timeline-date-locale-fallback.test.tsx` for why it cannot be.
@@ -191,18 +194,27 @@ describe('en session — output is byte-identical to the retired en-US (must-not
});
describe('non-date rendering is undisturbed (green both sides)', () => {
- it('the locale-free header vocabularies stay exactly as they were — zh', () => {
+ it('the locale-free header vocabularies do not go through `Intl` — zh', () => {
// `Week n` / `Qn YYYY` / `YYYY` never went through `Intl`, so threading a
- // locale must not touch them. (The first two ARE English on a zh axis, but
- // they need the package's translate channel rather than a locale tag —
- // filed as objectui#4520. Converting them here would be an unrelated
- // behavior change, and this case pins that #4513 left them alone.)
+ // locale must not touch them — which is what this case pins, on both sides
+ // of #4513.
+ //
+ // ⚠️ AUTHORIZED PIN MOVE (objectui#4520). The first two expectations read
+ // `Week 1` / `Q3 2026` when #4513 wrote them: that card was scoped to the
+ // five `Intl` sites, so it deliberately pinned the two bucket labels
+ // STAYING English to keep its own scope honest, and filed the English as
+ // objectui#4520. #4520 then routed them through the package's translate
+ // channel, so the expected strings — and only they — move here. What the
+ // case asserts is unchanged: threading a LOCALE did not touch them, and a
+ // locale tag is still not what spells them. The `year` header below stays
+ // exactly as it was; `String(getFullYear())` is a bare number with no
+ // vocabulary in it, so no channel has anything to say about it.
const week = renderSession('zh', );
- expect(week.container.textContent).toContain('Week 1');
+ expect(week.container.textContent).toContain('第 1 周');
cleanup();
const quarter = renderSession('zh', );
- expect(quarter.container.textContent).toContain('Q3 2026');
+ expect(quarter.container.textContent).toContain('2026年第3季度');
cleanup();
const year = renderSession('zh', );
diff --git a/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary-defaults.test.ts b/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary-defaults.test.ts
new file mode 100644
index 000000000..abb537830
--- /dev/null
+++ b/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary-defaults.test.ts
@@ -0,0 +1,115 @@
+/**
+ * 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#4520 — `generateTimeScaleHeaders` as a pure function: the translate
+ * seam and its English last resort.
+ *
+ * ── Why this file mounts nothing ─────────────────────────────────────────
+ * There is no React here at all — no `render`, no provider, not even an import
+ * of one. That is deliberate (objectui#4514): the sibling
+ * `timeline-scale-vocabulary.test.tsx` mounts an `I18nProvider` in every case,
+ * and every mounted provider leaves react-i18next's GLOBAL instance on the
+ * language it was given, so a provider-less assertion sharing that file would
+ * resolve `'zh'` and pin test ordering instead of the fallback. Keeping the
+ * pure-function cases in their own file makes the separation structural rather
+ * than a comment someone has to remember.
+ *
+ * ── What the seam is ─────────────────────────────────────────────────────
+ * `generateTimeScaleHeaders` is a pure exported function, so it cannot host a
+ * hook. objectui#4513 threaded the resolved `locale` string in as a parameter
+ * with an `'en'` default; this card threads the translate fn the same way, for
+ * the same reason and with the same shape — the default is the package's own
+ * `TIMELINE_DEFAULT_TRANSLATIONS` table, which is exactly what
+ * `createSafeTranslation` serves on a provider-less host. So an existing
+ * 3- or 4-argument call site keeps producing byte-identical English.
+ *
+ * ── Directions ───────────────────────────────────────────────────────────
+ * The English cases are GREEN on both sides of the fix by construction — the
+ * `en` pack values are byte-identical to the retired `Week ${n}` /
+ * `Q${q} ${year}` literals, which is the whole point of the must-not-change
+ * half. The RED-first evidence here is the seam itself: before the change the
+ * function takes no translate fn, so the 5th argument is silently dropped and
+ * the explicit-`t` cases fail.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { generateTimeScaleHeaders } from '../renderer';
+import { TIMELINE_DEFAULT_TRANSLATIONS } from '../useTimelineTranslation';
+
+const AUG_11 = '2026-08-11T00:00:00';
+const AUG_12 = '2026-08-12T00:00:00';
+const SEP_11 = '2026-09-11T00:00:00';
+
+describe('the English last resort is byte-identical to the retired literals', () => {
+ it('the 3-argument week call still reads `Week n`', () => {
+ expect(generateTimeScaleHeaders('week', AUG_11, AUG_12)).toEqual(['Week 1']);
+ expect(generateTimeScaleHeaders('week', AUG_11, SEP_11)).toEqual([
+ 'Week 1',
+ 'Week 2',
+ 'Week 3',
+ 'Week 4',
+ 'Week 5',
+ ]);
+ });
+
+ it('the 3-argument quarter call still reads `Qn YYYY` (the objectui#2942 pin)', () => {
+ expect(generateTimeScaleHeaders('quarter', '2026-01-15', '2026-08-01')).toEqual([
+ 'Q1 2026',
+ 'Q2 2026',
+ 'Q3 2026',
+ ]);
+ });
+
+ it('the year scale carries no vocabulary and is untouched', () => {
+ expect(generateTimeScaleHeaders('year', '2025-06-01', '2027-01-01')).toEqual(['2025', '2026', '2027']);
+ });
+
+ it('the defaults table states those same two strings, so the seam has one source', () => {
+ // If these drift, the provider-less host and the `en` provider render
+ // different axes — the failure `createSafeTranslation` exists to prevent.
+ expect(TIMELINE_DEFAULT_TRANSLATIONS['timeline.scale.week']).toBe('Week {{n}}');
+ expect(TIMELINE_DEFAULT_TRANSLATIONS['timeline.scale.quarter']).toBe('Q{{quarter}} {{year}}');
+ expect(TIMELINE_DEFAULT_TRANSLATIONS['timeline.gantt.rowLabel']).toBe('Items');
+ });
+});
+
+describe('an explicit translate fn drives both bucket scales', () => {
+ it('the week bucket asks the channel, and passes the number as a parameter', () => {
+ const t = vi.fn((key: string, params?: Record) => `${key}:${JSON.stringify(params)}`);
+ const headers = generateTimeScaleHeaders('week', AUG_11, SEP_11, 'zh', t);
+
+ expect(headers[0]).toBe('timeline.scale.week:{"n":1}');
+ expect(headers[1]).toBe('timeline.scale.week:{"n":2}');
+ // The number reaches the channel as DATA, never spliced into the key or
+ // concatenated onto the returned string.
+ expect(t).toHaveBeenCalledWith('timeline.scale.week', { n: 1 });
+ });
+
+ it('the quarter bucket passes the quarter and the year as separate parameters', () => {
+ const t = vi.fn((key: string, params?: Record) => `${key}:${JSON.stringify(params)}`);
+ const headers = generateTimeScaleHeaders('quarter', '2026-11-15T00:00:00', '2027-02-01T00:00:00', 'zh', t);
+
+ expect(t).toHaveBeenCalledWith('timeline.scale.quarter', { quarter: 4, year: 2026 });
+ expect(t).toHaveBeenCalledWith('timeline.scale.quarter', { quarter: 1, year: 2027 });
+ expect(headers).toEqual([
+ 'timeline.scale.quarter:{"quarter":4,"year":2026}',
+ 'timeline.scale.quarter:{"quarter":1,"year":2027}',
+ ]);
+ });
+
+ it('the date scales do NOT go through the translate channel — they are `Intl`', () => {
+ // The division of labour from objectui#4513: a locale TAG formats dates, a
+ // translation spells vocabulary. A date scale asking `t()` would be this
+ // card overreaching into the other one's territory.
+ const t = vi.fn((key: string) => `TRANSLATED:${key}`);
+ expect(generateTimeScaleHeaders('month', AUG_11, SEP_11, 'zh', t)).toEqual(['2026年8月', '2026年9月']);
+ expect(generateTimeScaleHeaders('year', '2025-06-01', '2027-01-01', 'zh', t)).toEqual(['2025', '2026', '2027']);
+ expect(t).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary.test.tsx
new file mode 100644
index 000000000..186dd9562
--- /dev/null
+++ b/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary.test.tsx
@@ -0,0 +1,199 @@
+/**
+ * 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#4520 — the gantt BUCKET vocabulary and the row-label default speak
+ * the session language.
+ *
+ * ── The measured defect ──────────────────────────────────────────────────
+ * objectui#4513 threaded `useDisplayLocale()` through every `Intl` call in
+ * `renderer.tsx`, so a `zh` session renders `2026年8月` on the month axis and
+ * `2026年8月11日` on item dates. Three sibling strings in the same renderer
+ * never went through `Intl` at all and stayed English on that same Chinese
+ * axis:
+ *
+ * week header `Week ${n}` -> `Week 1`
+ * quarter header `Q${q} ${year}` -> `Q3 2026`
+ * row label `schema.rowLabel || 'Items'` -> `Items`
+ *
+ * The half-fixed state is the visible one: a Chinese date axis with English
+ * bucket labels beside it.
+ *
+ * ── Why this is a translation card and not a locale-resolver one ──────────
+ * No `Intl` call is involved in any of the three. A locale TAG cannot spell
+ * `第 1 周`; only a translation can. So these resolve through the package's
+ * own channel — `useTimelineTranslation` / `TIMELINE_DEFAULT_TRANSLATIONS`,
+ * the `createSafeTranslation` factory `ObjectTimeline` already uses for
+ * `timeline.bucket.*` — and the numbers ride the channel's own `{{hole}}`
+ * parameter mechanism rather than being concatenated into the string.
+ *
+ * ── The two channels, and why they can disagree ───────────────────────────
+ * They are deliberately different resolvers and this file pins that:
+ *
+ * dates -> `useDisplayLocale()` : tenant regional default -> UI language -> `en`
+ * vocabulary -> the translate channel : the UI language the user reads
+ *
+ * A tenant that configured the `en` regional default while the user reads
+ * Chinese chrome therefore renders `Aug 2026` beside `第 1 周`. That is the
+ * repo's established split, not a defect of this change — `timeline.bucket.*`
+ * has behaved this way in `ObjectTimeline` all along — and objectui#4513's
+ * precedence pin is about DATES, which this card must not move.
+ *
+ * ── Directions ───────────────────────────────────────────────────────────
+ * Reverting `renderer.tsx` to its pre-#4520 state turns the three `zh session`
+ * vocabulary cases RED and leaves every `en session` case GREEN. The `en` half
+ * is the must-not-change pin: the translated `en` values are byte-identical to
+ * the retired hardcoded literals, so English rendering must not move by one
+ * character.
+ *
+ * ⚠️ Every case here mounts an `I18nProvider`. Provider-LESS assertions must
+ * NOT be added to this file (objectui#4514): `useObjectTranslation()` outside a
+ * provider reports the language of react-i18next's GLOBAL instance, which every
+ * mounted provider leaves on whatever language it was given, so a provider-less
+ * case written after a `zh` case here would resolve `'zh'` and pin test
+ * ordering rather than the fallback. The channel's provider-less last resort
+ * lives in `timeline-scale-vocabulary-defaults.test.ts`, which mounts nothing
+ * and renders nothing.
+ */
+
+import React from 'react';
+import { render, screen, cleanup } from '@testing-library/react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
+import { TimelineRenderer } from '../renderer';
+
+/**
+ * Explicit local time-of-day rather than bare `YYYY-MM-DD`, which `Date` parses
+ * as UTC midnight: a runner west of UTC would render the neighbouring day and
+ * the pins would read as vocabulary failures when they were timezone failures.
+ */
+const AUG_11 = '2026-08-11T00:00:00';
+const AUG_12 = '2026-08-12T00:00:00';
+const SEP_11 = '2026-09-11T00:00:00';
+
+/** A session: the UI language the user picked, plus the tenant's regional
+ * default (usually absent — the state the card was measured in). */
+function renderSession(language: string, node: React.ReactNode, tenantLocale?: string) {
+ return render(
+
+ {node}
+ ,
+ );
+}
+
+/**
+ * A gantt schema pinned to an explicit range, so the axis never depends on
+ * today's date. `rowLabel` is left UNSET — the defect is in the default, and an
+ * author who supplies one supplies their own string (pinned separately below).
+ */
+const gantt = (scale: string, minDate: string, maxDate: string) =>
+ ({
+ type: 'timeline',
+ variant: 'gantt',
+ scale,
+ minDate,
+ maxDate,
+ items: [{ label: 'Backend', items: [{ title: 'API Design', startDate: minDate, endDate: maxDate }] }],
+ }) as any;
+
+afterEach(() => cleanup());
+
+describe('zh session — the gantt bucket vocabulary is Chinese (objectui#4520)', () => {
+ it('the week header reads the translated bucket, not `Week n`', () => {
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('第 1 周');
+ expect(container.textContent).not.toContain('Week 1');
+ });
+
+ it('the week number rides the channel’s parameter mechanism across several buckets', () => {
+ // Three weeks of range: if the number were concatenated rather than
+ // interpolated, the second and third buckets would be the giveaway.
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('第 1 周');
+ expect(container.textContent).toContain('第 2 周');
+ expect(container.textContent).not.toContain('Week');
+ });
+
+ it('the quarter header reads the translated bucket, not `Qn YYYY`', () => {
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('2026年第3季度');
+ expect(container.textContent).not.toContain('Q3 2026');
+ });
+
+ it('the quarter number and the year are separate parameters, not one string', () => {
+ // A range crossing a year boundary: `zh` puts the year FIRST, so a value
+ // built by concatenating `Q${q} ${year}` cannot produce this ordering at
+ // all — this is the case that fails if the interpolation is faked.
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('2026年第4季度');
+ expect(container.textContent).toContain('2027年第1季度');
+ });
+
+ it('the unset row label reads the translated default, not `Items`', () => {
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('条目');
+ expect(container.textContent).not.toContain('Items');
+ });
+});
+
+describe('en session — byte-identical to the retired literals (must-not-change)', () => {
+ it('the week header still reads `Week 1`', () => {
+ const { container } = renderSession('en', );
+ expect(container.textContent).toContain('Week 1');
+ });
+
+ it('the week counter still increments per bucket', () => {
+ const { container } = renderSession('en', );
+ expect(container.textContent).toContain('Week 1');
+ expect(container.textContent).toContain('Week 2');
+ });
+
+ it('the quarter header still reads `Q3 2026`', () => {
+ const { container } = renderSession('en', );
+ expect(container.textContent).toContain('Q3 2026');
+ });
+
+ it('the unset row label still reads `Items`', () => {
+ const { container } = renderSession('en', );
+ expect(container.textContent).toContain('Items');
+ });
+});
+
+describe('what this card must NOT move (green both sides)', () => {
+ it('the year header is a bare number and stays one — zh', () => {
+ // `String(getFullYear())` carries no vocabulary at all. Translating it
+ // would be inventing a bucket label the card did not ask for.
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('2026');
+ });
+
+ it('an author-supplied rowLabel wins over the translated default — zh', () => {
+ // Only the DEFAULT was hardcoded. An author who writes `rowLabel` supplies
+ // their own string and no translation may overwrite it.
+ const schema = { ...gantt('month', AUG_11, SEP_11), rowLabel: 'Projects' };
+ renderSession('zh', );
+ expect(screen.getByText('Projects')).toBeDefined();
+ });
+
+ it('the date axis still follows the DATE channel, tenant first (objectui#4513)', () => {
+ // The tenant configured `en`; the user reads `zh`. Dates are the tenant's
+ // (`Aug 2026`), vocabulary is the user's — two resolvers, by design.
+ const { container } = renderSession('zh', , 'en');
+ expect(container.textContent).toContain('Aug 2026');
+ expect(container.textContent).not.toContain('2026年8月');
+ });
+
+ it('non-date, non-bucket rendering is untouched — zh', () => {
+ renderSession('zh', );
+ expect(screen.getByText('Backend')).toBeDefined();
+ expect(screen.getByText('API Design')).toBeDefined();
+ });
+});
diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx
index 616c1888c..29e4865a4 100644
--- a/packages/plugin-timeline/src/renderer.tsx
+++ b/packages/plugin-timeline/src/renderer.tsx
@@ -30,6 +30,11 @@ import {
} from './index';
import { renderChildren, cn } from '@object-ui/components';
import { useDisplayLocale } from '@object-ui/i18n';
+import {
+ useTimelineTranslation,
+ translateTimelineDefault,
+ type TimelineTranslate,
+} from './useTimelineTranslation';
// Constants
/**
@@ -67,12 +72,22 @@ export function resolveTimelineScale(schema: { scale?: unknown; timeScale?: unkn
* `useDisplayLocale()` falls back to, and byte-identical to the retired
* `'en-US'` at all three sites — so the existing 3-argument call sites keep
* producing exactly what they produced before.
+ *
+ * `t` is threaded on the same seam and for the same reason (#4520). The two are
+ * different kinds of dependency and each covers what the other cannot: a locale
+ * TAG formats a date, a TRANSLATION spells a word. The `week` and `quarter`
+ * branches never touched `Intl`, so #4513 left them reading `Week 1` / `Q3
+ * 2026` on an axis that had just become Chinese. Its default is the package's
+ * own defaults table, which is what the channel serves with no `I18nProvider`
+ * mounted, so 3- and 4-argument call sites keep producing byte-identical
+ * English.
*/
export function generateTimeScaleHeaders(
scale: string,
minDate: string,
maxDate: string,
locale: string = 'en',
+ t: TimelineTranslate = translateTimelineDefault,
): string[] {
const headers: string[] = [];
const start = new Date(minDate);
@@ -95,7 +110,7 @@ export function generateTimeScaleHeaders(
case 'week': {
let week = 1;
while (current <= end) {
- headers.push(`Week ${week++}`);
+ headers.push(t('timeline.scale.week', { n: week++ }));
current.setDate(current.getDate() + 7);
}
break;
@@ -103,7 +118,12 @@ export function generateTimeScaleHeaders(
case 'quarter':
current.setMonth(Math.floor(current.getMonth() / 3) * 3, 1);
while (current <= end) {
- headers.push(`Q${Math.floor(current.getMonth() / 3) + 1} ${current.getFullYear()}`);
+ headers.push(
+ t('timeline.scale.quarter', {
+ quarter: Math.floor(current.getMonth() / 3) + 1,
+ year: current.getFullYear(),
+ }),
+ );
current.setMonth(current.getMonth() + 3);
}
break;
@@ -243,6 +263,19 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
// are module-level functions and cannot host a hook themselves.
const displayLocale = useDisplayLocale();
+ // The package's translate channel, read on the same terms and for the same
+ // structural reason as the locale above: one read, above every variant's
+ // early return, threaded down into the module-level helpers that cannot
+ // host a hook (#4520).
+ //
+ // It is a SECOND channel, not a duplicate of the first, and the two are
+ // allowed to disagree: `useDisplayLocale()` puts the tenant's regional
+ // default first (how this org writes dates), while `t` follows the UI
+ // language (what this user reads). A tenant configured `en` with a user
+ // reading Chinese chrome therefore renders `Aug 2026` beside `第 1 周` —
+ // the same split `timeline.bucket.*` has always had in `ObjectTimeline`.
+ const { t } = useTimelineTranslation();
+
// Vertical Timeline
if (variant === 'vertical') {
// Detect whether the data was annotated with a `group` key
@@ -386,6 +419,7 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
minDate,
maxDate,
displayLocale,
+ t,
);
return (
@@ -394,7 +428,7 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
- {schema.rowLabel || 'Items'}
+ {schema.rowLabel || t('timeline.gantt.rowLabel')}
diff --git a/packages/plugin-timeline/src/useTimelineTranslation.ts b/packages/plugin-timeline/src/useTimelineTranslation.ts
index 43096e36c..dae3484f9 100644
--- a/packages/plugin-timeline/src/useTimelineTranslation.ts
+++ b/packages/plugin-timeline/src/useTimelineTranslation.ts
@@ -28,10 +28,50 @@ export const TIMELINE_DEFAULT_TRANSLATIONS: Record = {
'timeline.relative.yesterday': 'Yesterday',
'timeline.relative.inDays': 'In {{n}} days',
'timeline.relative.daysAgo': '{{n}} days ago',
+ // Gantt axis vocabulary (objectui#4520). These were `Week ${n}` and
+ // `Q${q} ${year}` template literals in `renderer.tsx`, so a zh session read
+ // an English bucket label beside the Chinese date axis objectui#4513 had just
+ // fixed. The numbers are `{{holes}}` rather than concatenation because the
+ // word order is the translation's to choose: `zh` puts the year first
+ // (`2026年第3季度`), which no `Q${q} ${year}` template can produce.
+ 'timeline.scale.week': 'Week {{n}}',
+ 'timeline.scale.quarter': 'Q{{quarter}} {{year}}',
+ 'timeline.gantt.rowLabel': 'Items',
};
const TEST_KEY = 'timeline.bucket.today';
+/**
+ * The translate fn shape this package threads into its pure helpers.
+ *
+ * `generateTimeScaleHeaders` is a module-level function and cannot host a hook,
+ * so the component reads the channel and passes `t` down — the same seam
+ * objectui#4513 opened for the resolved `locale` string, for the same reason.
+ */
+export type TimelineTranslate = (key: string, params?: Record) => string;
+
+/**
+ * The English last resort for call sites that have no `t` to hand: the exported
+ * helper's default parameter, and nothing else.
+ *
+ * It is the same lookup `createSafeTranslation` serves on a provider-less host,
+ * spelled here because that one is a hook and this is a pure function. Both
+ * halves are deliberate mirrors of `useSafeTranslation.ts` and must stay that
+ * way — a divergence would only ever show up on hosts with no `I18nProvider`,
+ * where we are least likely to see it:
+ *
+ * - `||` (not `??`) for the chain, so an empty string falls through to the
+ * key exactly as the hook's `defaults[key] || …` chain does.
+ * - `split().join()` (not `replace`/`replaceAll`) for interpolation, which is
+ * literal on both sides and substitutes EVERY occurrence — i18next's own
+ * behaviour, and the objectui#3418 fix.
+ */
+export const translateTimelineDefault: TimelineTranslate = (key, params) =>
+ Object.entries(params ?? {}).reduce(
+ (value, [name, v]) => value.split(`{{${name}}}`).join(String(v)),
+ TIMELINE_DEFAULT_TRANSLATIONS[key] || key,
+ );
+
/**
* Was a local re-implementation that wrapped the hook in try/catch — a
* rules-of-hooks violation (objectui#2879, same class as #2595/#2596).