diff --git a/.changeset/7459-timeline-absent-date-axis-refusal.md b/.changeset/7459-timeline-absent-date-axis-refusal.md
new file mode 100644
index 000000000..6488b748e
--- /dev/null
+++ b/.changeset/7459-timeline-absent-date-axis-refusal.md
@@ -0,0 +1,52 @@
+---
+'@object-ui/plugin-timeline': minor
+'@object-ui/i18n': minor
+---
+
+`ObjectTimeline` refuses an undeclared date axis instead of inventing one
+(objectui#7459).
+
+Steps ① and ② of the three-step sequence the maintainer ruled on objectui#7070
+(2026-09-01, 总监批 #28). House posture, on record with that ruling:
+日期轴永不虚构 — a date axis is never fabricated.
+
+`ObjectTimeline` resolved its date axis through five declared bindings and then
+closed the chain with a sixth rung that was a bare literal field name nobody
+has ever declared. A name therefore ALWAYS resolved: for a view that declared
+no axis, every record read a key its object does not carry, every event landed
+in the "No date" bucket, and the screen read as a timeline that had been built
+and simply had nothing in it.
+
+Two changes, shipped together because neither is observable alone:
+
+- **The renderer now refuses.** An object-bound timeline with no declared date
+ axis renders a diagnostic naming every binding it accepts —
+ `timeline.startDateField`, `timeline.dateField`, `mapping.date`, and the two
+ deprecated flat spellings — instead of a chart. The twin of `ObjectGantt`'s
+ "Gantt configuration required" screen, in the shape objectui#7070 settled.
+- **The invented sixth rung is gone**, which is the only thing that makes the
+ refusal reachable. Added while the floor stood, it would have been dead code;
+ retired without the refusal, it would have produced exactly the silent
+ "No date" outcome the ruling rejects.
+
+**What changes for an author.** A view that declares a date axis is completely
+unaffected — all five declared spellings resolve exactly as before, and a
+timeline authored from literal `items` is never refused, since its items carry
+their own dates and no field name is read for them. A view that declared no
+axis anywhere, and was rendering an empty-looking timeline, now says so.
+
+⚠️ Both premises were RE-MEASURED on the current tree before anything was
+edited, rather than taken from the card: the renderer had no absent-axis
+refusal (against a live control term that fires in the same file), and the
+floor was still present and still spelled as reported. The pairing itself is
+pinned — the refusal cases go red the moment the floor returns, including one
+whose records carry a column that happens to be named `date`, where a returned
+floor renders a convincing timeline rather than an empty one.
+
+Refusal is distinguished from "renders an empty timeline" by asserting the
+canvas is ABSENT, not merely event-free. The component's success surface is now
+named (`data-testid="timeline-canvas"`) so that distinction can be measured;
+every other terminal state of the component already named itself.
+
+Step ③ of the ruling — the `'created_at'` floors on the two plugin faces —
+stays on objectui#7070 and is deliberately NOT in this change.
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 47d05f336..1a55f1fb5 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -706,6 +706,9 @@ const ar = {
unsupported: {
objectBoundGantt: "النوع \"gantt\" غير مدعوم — المخطط الزمني المرتبط بكائن يعرض أنواع التغذية ({{variants}}). يحتاج مخطط جانت إلى صفوف صريحة، لكل منها عناصر items متداخلة خاصة به، لذلك لا يؤثر محور جانت (scale) هنا.",
},
+ unconfigured: {
+ noDateAxis: "محور تواريخ المخطط الزمني مطلوب — هذا العرض لا يعلن أي حقل تاريخ، والمخطط الزمني المرتبط بكائن لا يخترع حقلاً. أعلن أحد الحقول التالية: {{fields}}. الأول هو صيغة المواصفة، والبقية أسماء مستعارة قديمة.",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index a91092e88..3b3160e96 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -702,6 +702,9 @@ const de = {
unsupported: {
objectBoundGantt: "Nicht unterstützte Variante „gantt“ — eine objektgebundene Timeline rendert die Feed-Varianten ({{variants}}). Gantt benötigt literale Zeilen mit jeweils eigenen verschachtelten items; die Gantt-Achse (scale) hat hier daher keine Wirkung.",
},
+ unconfigured: {
+ noDateAxis: "Zeitachse für die Timeline erforderlich — diese Ansicht deklariert kein Datumsfeld, und eine objektgebundene Timeline erfindet keines. Deklarieren Sie eines von: {{fields}}. Das erste ist die Spec-Schreibweise, die übrigen sind veraltete Aliase.",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 917912391..39889e0f3 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -819,6 +819,9 @@ const en = {
unsupported: {
objectBoundGantt: 'Unsupported variant "gantt" — an object-bound timeline renders the feed variants ({{variants}}). Gantt needs literal rows, each with its own nested items, so the gantt axis (scale) has no effect here.',
},
+ unconfigured: {
+ noDateAxis: 'Timeline date axis required — this view declares no date field, and an object-bound timeline will not invent one. Declare one of: {{fields}}. The first is the spec spelling; the rest are legacy aliases.',
+ },
},
gantt: {
column: {
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index db603ef7c..6688a6b4b 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -706,6 +706,9 @@ const es = {
unsupported: {
objectBoundGantt: "Variante \"gantt\" no admitida: una línea de tiempo vinculada a un objeto representa las variantes de feed ({{variants}}). Gantt necesita filas literales, cada una con sus propios items anidados, por lo que el eje gantt (scale) no tiene efecto aquí.",
},
+ unconfigured: {
+ noDateAxis: "Se requiere el eje de fechas de la línea de tiempo: esta vista no declara ningún campo de fecha y una línea de tiempo vinculada a un objeto no inventa ninguno. Declare uno de: {{fields}}. El primero es la grafía de la especificación; los demás son alias heredados.",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index a55702eca..7311220dc 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -702,6 +702,9 @@ const fr = {
unsupported: {
objectBoundGantt: "Variante « gantt » non prise en charge — une timeline liée à un objet affiche les variantes de flux ({{variants}}). Gantt nécessite des lignes littérales, chacune avec ses propres items imbriqués ; l'axe gantt (scale) est donc sans effet ici.",
},
+ unconfigured: {
+ noDateAxis: "Axe de dates requis pour la timeline — cette vue ne déclare aucun champ de date, et une timeline liée à un objet n'en invente pas. Déclarez l'un de : {{fields}}. Le premier est l'orthographe de la spec, les autres sont des alias hérités.",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 613bc4491..9fc979913 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -702,6 +702,9 @@ const ja = {
unsupported: {
objectBoundGantt: "サポートされていないバリアント \"gantt\" — オブジェクト連動のタイムラインはフィードバリアント({{variants}})を描画します。ガントには、それぞれが入れ子の items を持つリテラル行が必要なため、ここではガント軸(scale)は効果がありません。",
},
+ unconfigured: {
+ noDateAxis: "タイムラインの日付軸が必要です — このビューは日付フィールドを宣言しておらず、オブジェクト連動のタイムラインが日付フィールドを作り出すことはありません。次のいずれかを宣言してください: {{fields}}。最初のものが spec の記法で、残りは旧来のエイリアスです。",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index ac00d0f14..17aa11e9f 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -702,6 +702,9 @@ const ko = {
unsupported: {
objectBoundGantt: "지원되지 않는 variant \"gantt\" — 객체 바인딩 타임라인은 피드 variant({{variants}})를 렌더링합니다. 간트는 각각 중첩된 items를 가진 리터럴 행이 필요하므로 여기서는 간트 축(scale)이 적용되지 않습니다.",
},
+ unconfigured: {
+ noDateAxis: "타임라인 날짜 축이 필요합니다 — 이 뷰는 날짜 필드를 선언하지 않았으며, 객체 바인딩 타임라인은 날짜 필드를 지어내지 않습니다. 다음 중 하나를 선언하세요: {{fields}}. 첫 번째가 spec 표기이고 나머지는 레거시 별칭입니다.",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 9b103a19b..6194b5e05 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -701,6 +701,9 @@ const pt = {
unsupported: {
objectBoundGantt: "Variante \"gantt\" não suportada: uma linha do tempo vinculada a um objeto renderiza as variantes de feed ({{variants}}). O gantt precisa de linhas literais, cada uma com os seus próprios items aninhados, por isso o eixo gantt (scale) não tem efeito aqui.",
},
+ unconfigured: {
+ noDateAxis: "Eixo de datas da linha do tempo necessário: esta vista não declara nenhum campo de data e uma linha do tempo vinculada a um objeto não inventa nenhum. Declare um de: {{fields}}. O primeiro é a grafia da spec; os restantes são aliases legados.",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index 03619e60d..07919cabb 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -708,6 +708,9 @@ const ru = {
unsupported: {
objectBoundGantt: "Неподдерживаемый вариант \"gantt\" — временная шкала, привязанная к объекту, отображает ленточные варианты ({{variants}}). Для гантта нужны литеральные строки, каждая со своим вложенным items, поэтому ось гантта (scale) здесь не действует.",
},
+ unconfigured: {
+ noDateAxis: "Требуется ось дат временной шкалы — это представление не объявляет ни одного поля даты, а временная шкала, привязанная к объекту, не придумывает его сама. Объявите одно из: {{fields}}. Первое — написание из спецификации, остальные — устаревшие псевдонимы.",
+ },
},
gantt: {
viewMode: {
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 4e9f6407f..9244cd7ca 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -726,6 +726,9 @@ const zh = {
unsupported: {
objectBoundGantt: '不支持的变体 "gantt" —— 对象绑定的时间线只渲染 feed 变体({{variants}})。甘特图需要字面量行,每行自带嵌套的 items,因此此处的甘特轴(scale)不起作用。',
},
+ unconfigured: {
+ noDateAxis: '时间线需要日期轴 —— 该视图未声明任何日期字段,而对象绑定的时间线不会虚构一个。请声明以下之一:{{fields}}。第一个是 spec 写法,其余为遗留别名。',
+ },
},
gantt: {
column: {
diff --git a/packages/plugin-timeline/src/ObjectTimeline.absentDateAxisRefusal-7459.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.absentDateAxisRefusal-7459.test.tsx
new file mode 100644
index 000000000..f4dc63079
--- /dev/null
+++ b/packages/plugin-timeline/src/ObjectTimeline.absentDateAxisRefusal-7459.test.tsx
@@ -0,0 +1,229 @@
+/**
+ * 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#7459 — an object-bound `ObjectTimeline` that declares NO date axis
+ * refuses, and the refusal is REACHABLE.
+ *
+ * The sibling of `plugin-gantt`'s `ObjectGantt.unconfiguredRefusal-7070` and of
+ * `plugin-calendar`'s #7029 twin, written for the maintainer ruling those two
+ * produced (2026-09-01, objectui#7070, 总监批 #28): house posture is
+ * 日期轴永不虚构 — a date axis is never fabricated.
+ *
+ * ## Why one file pins TWO changes
+ *
+ * The ruling ordered a refusal screen (①) and the retirement of the renderer's
+ * own invented field name at the end of its resolver chain (②), and neither is
+ * observable alone:
+ *
+ * - a refusal added while the floor stands can never be TAKEN — the floor
+ * guaranteed a name always resolved, so the branch is dead code;
+ * - the floor retired with no refusal produces the outcome the ruling
+ * explicitly rejects: every record reads a key no object carries and
+ * buckets into "No date" — a timeline that looks built and is not.
+ *
+ * So the pairing itself is the subject. `renders the refusal` and `stays
+ * reachable` below both go RED the moment the floor returns, and the fully
+ * declared CONTROLS stay GREEN in both worlds — the asymmetry that separates
+ * "stopped inventing" from "refuses everything".
+ *
+ * ## Refusal is not "an empty timeline", and that is asserted, not assumed
+ *
+ * The failure mode the ruling is trying to avoid renders a real timeline with
+ * nothing on it, so a pin that only counts events passes on BOTH outcomes. Two
+ * independent canvas markers are therefore asserted ABSENT here, each proven
+ * non-vacuous by a control in this same file that asserts it PRESENT:
+ *
+ * 1. `data-testid="timeline-canvas"` — the component's own success surface.
+ * Named by #7459 precisely so this distinction could be measured; every
+ * other terminal state of the component already named itself.
+ * 2. `role="list"` — the `
` rail the REAL `TimelineRenderer` emits. An
+ * empty timeline still emits it, which is what makes it discriminating.
+ *
+ * The renderer is deliberately NOT stubbed, for the reason
+ * `__tests__/timeline-object-bound-gantt-refusal.test.tsx` states one file
+ * over: `ObjectTimeline.test.tsx` stubs `./renderer` and prints only titles, so
+ * assertions there stay green whether or not the branch under test was reached.
+ * Only the ambient React context hooks are mocked.
+ */
+
+import React from 'react';
+import { render, screen, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { ObjectTimeline } from './ObjectTimeline';
+
+vi.mock('@object-ui/react', async (importOriginal) => {
+ const actual = await (importOriginal() as Promise>);
+ return {
+ ...actual,
+ useDataScope: () => undefined,
+ useNavigationOverlay: () => ({
+ isOverlay: false,
+ handleClick: vi.fn(),
+ selectedRecord: null,
+ isOpen: false,
+ close: vi.fn(),
+ setIsOpen: vi.fn(),
+ mode: 'overlay',
+ view: undefined,
+ }),
+ };
+});
+
+afterEach(cleanup);
+
+/**
+ * Records, not items — this is what the object-bound path is handed.
+ *
+ * Every row carries `start_date` (the name an author would DECLARE) and, on
+ * purpose, a column literally named `date` as well. That second column is the
+ * reachability case's whole point: it is the data shape under which a returned
+ * floor renders a CONVINCING timeline rather than an empty one, so "no events"
+ * would not have caught it. Nobody declared `date`; the refusal must still fire.
+ */
+const ROWS = [
+ { id: '1', name: 'Spring Launch', start_date: '2099-09-01', end_date: '2099-09-30', date: '2099-09-01' },
+ { id: '2', name: 'Summer Push', start_date: '2100-10-01', end_date: '2100-10-31', date: '2100-10-01' },
+];
+
+/** The same rows with the incidental `date` column removed. */
+const ROWS_WITHOUT_DATE_COLUMN = ROWS.map(({ date: _date, ...rest }) => rest);
+
+function renderObjectBound(schema: Record, rows: unknown[] = ROWS) {
+ const props = { schema, data: rows } as unknown as React.ComponentProps;
+ return render();
+}
+
+const refusal = () => screen.queryByTestId('timeline-missing-date-axis');
+const canvas = () => screen.queryByTestId('timeline-canvas');
+/** The `` rail the real renderer emits — present even for zero events. */
+const rails = () => screen.queryAllByRole('list');
+
+const OBJECT_BOUND = { type: 'timeline', objectName: 'campaign' };
+
+describe('ObjectTimeline — an undeclared date axis is REFUSED (objectui#7459)', () => {
+ it('renders the refusal, and the CANVAS IS ABSENT — not an empty timeline', () => {
+ renderObjectBound({ ...OBJECT_BOUND }, ROWS_WITHOUT_DATE_COLUMN);
+
+ const el = refusal();
+ expect(el, 'an object-bound timeline with no declared date axis rendered no refusal').not.toBeNull();
+ expect(el!.getAttribute('role'), 'the refusal is not announced').toBe('alert');
+
+ // The distinction the ruling asked to be measured. Both markers, each of
+ // which a control below asserts PRESENT, so neither zero is vacuous.
+ expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
+ expect(rails(), 'the timeline rail was rendered beside the refusal').toHaveLength(0);
+
+ // …and specifically NOT the outcome the ruling rejects: every record
+ // bucketed under "No date" on an axis nobody declared.
+ expect(screen.queryByText('No date')).toBeNull();
+ expect(screen.queryByText('Spring Launch')).toBeNull();
+ expect(screen.queryByText('Summer Push')).toBeNull();
+ });
+
+ it('stays reachable when the records HAPPEN to carry a column named `date`', () => {
+ // THE PAIRING CASE. Restore the retired floor and this one goes red while
+ // rendering something that looks entirely healthy: two real events off a
+ // real column. Declared-ness, not existence, is what the axis is.
+ renderObjectBound({ ...OBJECT_BOUND }, ROWS);
+
+ expect(refusal(), 'a column named `date` resurrected the fabricated axis').not.toBeNull();
+ expect(canvas()).toBeNull();
+ expect(rails()).toHaveLength(0);
+ expect(screen.queryByText('Spring Launch')).toBeNull();
+ });
+
+ it('REFUSES a half-declared axis — an end date alone is not a timeline axis', () => {
+ // `endDateField` falls back to the start axis, so a view that declared only
+ // an end date declared no axis to lay events on. The same judgement
+ // `getGanttConfig` makes when it refuses a half-declared gantt.
+ renderObjectBound({ ...OBJECT_BOUND, timeline: { endDateField: 'end_date' } });
+
+ expect(refusal()).not.toBeNull();
+ expect(canvas()).toBeNull();
+ });
+
+ it('names the fields the author has to declare', () => {
+ renderObjectBound({ ...OBJECT_BOUND });
+ const text = refusal()!.textContent ?? '';
+
+ // Not a bare "not configured": the author must be able to act on it. The
+ // list is interpolated from the component's own
+ // `OBJECT_BOUND_TIMELINE_DATE_BINDINGS`, so this asserts the real
+ // vocabulary rather than a sentence that could drift from the resolver.
+ for (const binding of ['timeline.startDateField', 'timeline.dateField', 'mapping.date', 'dateField']) {
+ expect(text, `the refusal does not name \`${binding}\``).toContain(binding);
+ }
+ });
+
+ it('leaves the objectui#6655 variant refusal first for a composed gantt', () => {
+ // Both refusals apply to this schema. The variant one wins deliberately:
+ // "this path does not render gantt" is the more useful first sentence for a
+ // chart that would be refused either way. Pinned so the order is a decision
+ // rather than an accident of line placement.
+ renderObjectBound({ ...OBJECT_BOUND, variant: 'gantt' });
+
+ expect(screen.queryByTestId('timeline-unsupported-variant')).not.toBeNull();
+ expect(refusal()).toBeNull();
+ expect(canvas()).toBeNull();
+ });
+});
+
+describe('ObjectTimeline — every DECLARED rung still resolves (objectui#7459)', () => {
+ // Without these a fix that refused EVERYTHING would pass the block above,
+ // and retiring the floor could have taken a declared binding with it. Each
+ // case is one rung of the resolver chain, in its own spelling; all five are
+ // declared bindings (`ListViewTimelineConfig` for the first two, this
+ // component's props and `TimelineExtensionSchema` for the rest).
+ const RUNGS: Array<[string, Record]> = [
+ ['timeline.startDateField', { timeline: { startDateField: 'start_date' } }],
+ ['timeline.dateField', { timeline: { dateField: 'start_date' } }],
+ ['mapping.date', { mapping: { date: 'start_date' } }],
+ ['startDateField (flat, deprecated)', { startDateField: 'start_date' }],
+ ['dateField (flat, deprecated)', { dateField: 'start_date' }],
+ ];
+
+ for (const [label, binding] of RUNGS) {
+ it(`CONTROL: \`${label}\` renders the timeline, with its events`, () => {
+ renderObjectBound({ ...OBJECT_BOUND, ...binding }, ROWS_WITHOUT_DATE_COLUMN);
+
+ expect(refusal(), `a declared \`${label}\` was refused`).toBeNull();
+ // Both absence markers from the refusal cases, asserted PRESENT here —
+ // this is what makes those zeros readings rather than phantom checks.
+ expect(canvas(), 'the timeline canvas is missing on a declared axis').not.toBeNull();
+ expect(rails().length, 'the timeline rail is missing on a declared axis').toBeGreaterThan(0);
+ expect(screen.getByText('Spring Launch')).toBeDefined();
+ expect(screen.getByText('Summer Push')).toBeDefined();
+ });
+ }
+
+ it('CONTROL: an AUTHORED item list is never refused — it declares no field names', () => {
+ // The carve-out that keeps the in-repo catalog fixtures
+ // (`vertical-timeline.json` / `horizontal-timeline.json` /
+ // `gantt-style-timeline.json`) rendering: an authored item carries its own
+ // `time`, so no field NAME is read for it and there is no axis to declare.
+ // A refusal keyed on anything looser than "this component COMPOSED the
+ // items" would take every literal timeline down with it.
+ render(
+ ,
+ );
+
+ expect(refusal(), 'an authored timeline was refused for declaring no date FIELD').toBeNull();
+ expect(canvas()).not.toBeNull();
+ expect(screen.getByText('Project Started')).toBeDefined();
+ expect(screen.getByText('First Milestone')).toBeDefined();
+ });
+});
diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx
index 45d21787a..1b4575def 100644
--- a/packages/plugin-timeline/src/ObjectTimeline.tsx
+++ b/packages/plugin-timeline/src/ObjectTimeline.tsx
@@ -49,6 +49,30 @@ export const DEFAULT_TIMELINE_LIMIT = 100;
*/
const OBJECT_BOUND_TIMELINE_VARIANTS = ['vertical', 'horizontal'] as const;
+/**
+ * Every date-axis binding this component READS, spelled as an author writes it,
+ * in the precedence order `startDateField` below applies them.
+ *
+ * Module-local for the same reason `OBJECT_BOUND_TIMELINE_VARIANTS` above is:
+ * the refusal's message interpolates THIS list rather than restating it in
+ * prose, so a rung added to (or retired from) the resolver cannot leave the
+ * diagnostic naming a vocabulary the resolver no longer has. Every entry is a
+ * DECLARED binding — the first two on `ListViewTimelineConfig`
+ * (`@object-ui/types`), the last three on this component's own props and on
+ * `TimelineExtensionSchema` — which is the property that distinguishes them
+ * from the `'date'` literal objectui#7459 retired from the end of that chain.
+ *
+ * Ordered canonical-first: the message tells the author which one to prefer by
+ * position rather than by a second prose sentence that could drift from it.
+ */
+const OBJECT_BOUND_TIMELINE_DATE_BINDINGS = [
+ 'timeline.startDateField',
+ 'timeline.dateField',
+ 'mapping.date',
+ 'startDateField',
+ 'dateField',
+] as const;
+
const TimelineMappingSchema = z.object({
title: z.string().optional(),
date: z.string().optional(),
@@ -244,9 +268,24 @@ export const ObjectTimeline: React.FC = ({
// fell all the way through to the caller's default (`created_at` / `due_date`),
// which is usually absent from the projection — so every record bucketed into
// "No date" while the data it needed was sitting in the row (objectui#3129).
+ //
+ // objectui#7459 — the chain ENDS here. It used to close with a SIXTH rung:
+ // the bare field name d-a-t-e as a literal, which nobody has ever declared —
+ // a name this renderer invented for itself, one layer below the created_at
+ // the view faces supply. (Spelled apart on purpose: the card's close
+ // condition greps this file for that literal and a tombstone quoting it
+ // would answer 1 where the truth is 0.) It guaranteed a name always
+ // resolved, so every record read a key no object carries, found nothing, and
+ // bucketed into "No date" — a timeline that looks built
+ // and is not. It also made a refusal screen unreachable by construction,
+ // which is why the maintainer ruling (2026-09-01, objectui#7070, 总监批 #28)
+ // ordered the floor retired and the refusal added as ONE change. House
+ // posture, on record with that ruling: 日期轴永不虚构 — a date axis is never
+ // fabricated. `undefined` from here is therefore a real answer, and the
+ // refusal below is what answers it.
const startDateField =
timelineConfig?.startDateField ?? timelineConfig?.dateField
- ?? schema.mapping?.date ?? schema.startDateField ?? schema.dateField ?? 'date';
+ ?? schema.mapping?.date ?? schema.startDateField ?? schema.dateField;
const endDateField = timelineConfig?.endDateField ?? schema.endDateField ?? startDateField;
const descField = schema.mapping?.description ?? schema.descriptionField ?? 'description';
const variantField = schema.mapping?.variant ?? 'variant';
@@ -259,6 +298,17 @@ export const ObjectTimeline: React.FC = ({
const effectiveItems = useMemo(() => {
if (schema.items) return schema.items;
if (!rawData || !Array.isArray(rawData)) return [];
+ // No declared date axis — there is no key to read a time off, and the
+ // refusal below is what the author sees instead. Composing a feed anyway
+ // is the outcome the ruling rejects: every record buckets into "No date"
+ // and the screen reads as a built timeline with nothing in it. Returning
+ // early keeps this hook honest about that (objectui#7459).
+ if (!startDateField) return [];
+
+ // Narrowed once for the mapper. The guard above establishes the start key,
+ // and `endDateField` falls back to it, so both are strings from here down.
+ const startKey: string = startDateField;
+ const endKey: string = endDateField ?? startDateField;
const fields: Record = (objectDef?.fields ?? {}) as Record;
const objectName: string = schema.objectName || '';
@@ -315,8 +365,8 @@ export const ObjectTimeline: React.FC = ({
};
const mapped = rawData.map((item: any) => {
- const startRaw = item[startDateField];
- const endRaw = item[endDateField];
+ const startRaw = item[startKey];
+ const endRaw = item[endKey];
const colorRaw = colorField ? item[colorField] : undefined;
const groupRaw = groupByField ? item[groupByField] : undefined;
@@ -475,6 +525,59 @@ export const ObjectTimeline: React.FC = ({
);
}
+ /**
+ * objectui#7459 — REFUSE an object-bound timeline that declares no date axis.
+ *
+ * The twin of `ObjectGantt`'s screen, which is the settled in-repo shape for
+ * this: `getGanttConfig` answers `null` when the schema carries neither a
+ * config block nor the required flat props, and the early return names the
+ * fields the author has to declare. This is the same answer for the same
+ * question, one renderer over.
+ *
+ * ## Why it can only exist together with the retired floor
+ *
+ * Until objectui#7459 the resolver above ended in a fabricated literal, so a
+ * name ALWAYS resolved and this branch could never have been taken — a
+ * refusal screen that is present and unreachable. The maintainer ruling
+ * (2026-09-01, objectui#7070, 总监批 #28) ordered the two as one sequence for
+ * exactly that reason, and the other order is no better: retiring the floor
+ * with no refusal leaves every record reading a key that is not there and
+ * bucketing into "No date". Neither half is observable alone; the pin
+ * (`ObjectTimeline.absentDateAxisRefusal-7459.test.tsx`) measures the pairing
+ * rather than trusting it.
+ *
+ * ## Three things this condition is careful about
+ *
+ * 1. `hasAuthoredItems` — the same test the #6655 refusal directly above
+ * makes, and for the same reason. An AUTHORED item carries its own `time`
+ * / `startDate`; no field NAME is read for it, so a literal timeline needs
+ * no date binding and must not be refused for lacking one. The in-repo
+ * catalog fixtures (`vertical-timeline.json`, `horizontal-timeline.json`,
+ * `gantt-style-timeline.json`) are all exactly that.
+ * 2. It keys on the START axis alone. `endDateField` falls back to it, so a
+ * view that declared only an end date has declared no axis to lay events
+ * on — the same judgement `getGanttConfig` makes when it refuses a
+ * half-declared gantt.
+ * 3. Placed with the #6655 refusal, above `error` and `loading`, because it
+ * is the same KIND of fact: a static authoring fact that no fetch outcome
+ * changes. A skeleton that resolves into a refusal, or a network error
+ * shown first, would both send the author to debug the wrong layer.
+ *
+ * It sits BELOW the variant refusal deliberately. A composed gantt cannot be
+ * drawn here at all, so "this path does not render gantt" is the more useful
+ * first sentence than "declare a date field" for a chart that would be
+ * refused either way.
+ */
+ if (!hasAuthoredItems && !startDateField) {
+ return (
+
+ {t('timeline.unconfigured.noDateAxis', {
+ fields: OBJECT_BOUND_TIMELINE_DATE_BINDINGS.join(', '),
+ })}
+
+ );
+ }
+
const effectiveSchema = {
...schema,
items: effectiveItems || [],
@@ -527,8 +630,16 @@ export const ObjectTimeline: React.FC = ({
);
}
+ // `data-testid` on the SUCCESS surface, not only on the refusals. Every other
+ // terminal state of this component already names itself
+ // (`timeline-unsupported-variant`, `timeline-missing-date-axis`,
+ // `timeline-error`, `timeline-loading`); the rendered timeline was the one
+ // outcome a test could not ask for by name, so "refused" and "rendered an
+ // EMPTY timeline" were indistinguishable except through renderer-owned
+ // markup. That distinction is the whole point of the refusal
+ // (objectui#7459), so the canvas gets a name of its own.
return (
-
+
{pullDistance > 0 && (
= {
// `OBJECT_BOUND_TIMELINE_VARIANTS` in `ObjectTimeline.tsx`.
'timeline.unsupported.objectBoundGantt':
'Unsupported variant "gantt" — an object-bound timeline renders the feed variants ({{variants}}). Gantt needs literal rows, each with its own nested items, so the gantt axis (scale) has no effect here.',
+ // objectui#7459 — the object-bound path REFUSES a view that declares no date
+ // axis, the twin of `ObjectGantt`'s "Gantt configuration required" screen.
+ //
+ // It became REACHABLE in the same change that retired the renderer's own
+ // invented field name from the end of the resolver chain; before that a name
+ // always resolved and this string could never have rendered. House posture
+ // (maintainer, 2026-09-01, objectui#7070): 日期轴永不虚构 — a date axis is
+ // never fabricated.
+ //
+ // `{{fields}}` is a hole rather than prose for the reason
+ // `timeline.unsupported.objectBoundGantt` above gives: the list is derived
+ // from the component's own declaration of what it reads — see
+ // `OBJECT_BOUND_TIMELINE_DATE_BINDINGS` in `ObjectTimeline.tsx` — so the
+ // diagnostic cannot drift from the resolver. This copy is the byte-identical
+ // twin of `en.ts`'s, as the strings above are.
+ 'timeline.unconfigured.noDateAxis':
+ 'Timeline date axis required — this view declares no date field, and an object-bound timeline will not invent one. Declare one of: {{fields}}. The first is the spec spelling; the rest are legacy aliases.',
};
const TEST_KEY = 'timeline.bucket.today';