diff --git a/.changeset/6469-gantt-block-precedence.md b/.changeset/6469-gantt-block-precedence.md
new file mode 100644
index 000000000..ffb8feea5
--- /dev/null
+++ b/.changeset/6469-gantt-block-precedence.md
@@ -0,0 +1,38 @@
+---
+'@object-ui/plugin-gantt': minor
+'@object-ui/types': minor
+---
+
+**plugin-gantt: the `gantt` block now outranks the flat top-level spelling, and
+the losing face's keys are named instead of dropped.**
+
+`getGanttConfig` checked the flattened top-level spelling first and returned
+early, so a node carrying both spellings rendered the flat one and every key
+inside an authored `gantt` block was discarded with **no diagnostic** — not even
+the `GanttConfigSchema.safeParse` warning, which sat behind that early return.
+
+`plugin-map` had the identical two-faces shape ruled the other way (maintainer
+ruling on objectui#5018, 2026-08-17, landed in PR #5156): the block wins, with a
+dev-mode warning naming the ignored top-level keys. objectui#6469 inherits that
+ruling, so the two sibling view plugins now answer the same question the same
+way.
+
+What changes:
+
+- A node carrying **both** spellings now renders the **`gantt` block's** values.
+ The block is taken **whole** — the flat keys are not merged into it.
+- In dev, `[ObjectGantt] … these top-level keys are IGNORED: …` names every
+ shadowed flat key, once per distinct shadowing.
+- Nothing else moves. A node with only the flat spelling, or only a block, is
+ read exactly as before.
+
+**Producer-safe:** `ObjectView` (`case 'gantt'`) and `ListView` (`case 'gantt'`)
+both flatten `options.gantt` onto top-level keys and emit **no** `gantt` key, so
+every gantt reached through either view layer still takes the flat branch, and
+the new warning cannot fire on that path. This is the same producer check the
+`plugin-map` flip pinned, re-run on today's `main`.
+
+This **supersedes** the precedence sentence in the objectui#6051 changeset
+(`.changeset/6051-gantt-flat-config-declared-keys.md`), which recorded the flat
+branch winning — accurate for that change, which deliberately did not touch
+precedence, and reversed by this one.
diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md
index 68ddba5dc..6c651d337 100644
--- a/packages/plugin-gantt/README.md
+++ b/packages/plugin-gantt/README.md
@@ -227,15 +227,24 @@ chart renders empty:
`{ provider: 'schema', schemaId }`.
**2. How the fields map — `getGanttConfig`.** Two spellings, checked in order.
-Top-level keys are used **only when `startDateField` and `endDateField` are both
-present**; otherwise the whole `gantt` block is read instead:
+The **`gantt` block wins whenever it is present**, and it is taken WHOLE — the
+flat top-level keys are not merged into it. The flat spelling is read only when
+there is no `gantt` block, and then only when `startDateField` and
+`endDateField` are both present. A node carrying both spellings renders the
+block's values and gets a dev-mode warning naming the ignored top-level keys.
+
+Precedence follows the maintainer ruling on objectui#5018 (2026-08-17), which
+settled the identical two-faces shape for `plugin-map`; objectui#6469 inherited
+it here. Before that flip the flat branch returned first, so an authored `gantt`
+block was discarded silently.
```typescript
{
type: 'gantt',
objectName: 'project_tasks',
- // (a) flat spelling — requires BOTH date fields to be taken
+ // (a) flat spelling — read only when there is no `gantt` block,
+ // and then only with BOTH date fields present
startDateField: 'start_date',
endDateField: 'end_date',
titleField: 'name', // defaults to 'name'
@@ -246,7 +255,7 @@ present**; otherwise the whole `gantt` block is read instead:
typeField: 'task_kind',
viewMode: 'week', // 'day'|'week'|'month'|'quarter'|'year'
- // (b) …or the same configuration as one block:
+ // (b) …or the same configuration as one block, which OUTRANKS (a):
// gantt: { startDateField: 'start_date', endDateField: 'end_date', … }
}
```
@@ -255,9 +264,10 @@ present**; otherwise the whole `gantt` block is read instead:
spec's `GanttConfigSchema.viewMode`) and is honoured by **both** renderer
branches — the timeline and the resource-workload grid. It reaches the renderer
through `getGanttConfig`, so it only takes effect alongside a taken gantt
-config: as a top-level key it needs `startDateField` + `endDateField` beside it,
-or it can sit inside the `gantt` block. Omitting it is meaningful — a persisted
-layout then seeds the granularity before the renderer's `'day'` fallback.
+config: as a top-level key it needs `startDateField` + `endDateField` beside it
+and no `gantt` block on the node, or it can sit inside the `gantt` block.
+Omitting it is meaningful — a persisted layout then seeds the granularity before
+the renderer's `'day'` fallback.
#### Keys this page used to teach that the renderer never reads
diff --git a/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx
new file mode 100644
index 000000000..06a619038
--- /dev/null
+++ b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx
@@ -0,0 +1,282 @@
+/**
+ * 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#6469 — the `gantt` BLOCK outranks the flat top-level spelling, and
+ * the losing face's keys are NAMED in a dev-mode warning.
+ *
+ * ## What changed and why the pin is written this way
+ *
+ * `getGanttConfig` used to check the flat spelling FIRST and `return` early, so
+ * a node carrying both spellings rendered the flat one and every key inside the
+ * authored `gantt` block was discarded with NO diagnostic — not even the
+ * `GanttConfigSchema.safeParse` warning, which lived behind that early return.
+ * `plugin-map` had the identical two-faces shape ruled the other way (maintainer
+ * on objectui#5018, 2026-08-17, landed PR #5156); this card inherits that
+ * ruling, so the block wins and the shadowed flat keys are named.
+ *
+ * The precedence pin below is written as a RENDERED-VALUES assertion rather than
+ * a call-level one: the two faces name DIFFERENT record fields, so the direction
+ * of the flip is visible in the bars themselves. Before the flip the same
+ * fixture rendered `FLAT …` titles spanning March; after it, `BLOCK …` titles
+ * spanning January.
+ *
+ * ## Why the warning cannot spray
+ *
+ * The flat branch is the HOT path for gantt in practice: `ObjectView`
+ * (`case 'gantt'`) and `ListView` (`case 'gantt'`) both FLATTEN `options.gantt`
+ * onto top-level keys and emit NO `gantt` key at all, so a hand-authored block
+ * reaching this component through either view layer has already been flattened
+ * before `getGanttConfig` sees it. The warning is raised only from the block
+ * branch, which their output never enters — pinned directly below by the
+ * "flatten product" case.
+ */
+import React from 'react';
+import { render, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import type { GanttConfig } from '@object-ui/types';
+import { ObjectGantt, FLAT_GANTT_CONFIG_KEYS, type KnownGanttConfigKey } from './ObjectGantt';
+
+vi.mock('./GanttView', () => ({
+ GanttView: ({ tasks }: any) => (
+
t.title).join('|')}
+ data-starts={tasks
+ .map((t: any) => (t.start instanceof Date ? t.start.toISOString().slice(0, 10) : String(t.start)))
+ .join('|')}
+ />
+ ),
+}));
+
+vi.mock('./ResourceWorkload', () => ({
+ ResourceWorkload: ({ tasks }: any) =>
,
+}));
+
+/**
+ * One record carrying BOTH faces' field names, with values that cannot be
+ * confused: the block's fields say "BLOCK"/January, the flat spelling's say
+ * "FLAT"/March.
+ */
+const INLINE = [
+ {
+ id: '1',
+ block_name: 'BLOCK Alpha',
+ flat_name: 'FLAT Alpha',
+ b_start: '2024-01-01',
+ b_end: '2024-01-05',
+ f_start: '2024-03-01',
+ f_end: '2024-03-05',
+ },
+];
+
+const BLOCK = { startDateField: 'b_start', endDateField: 'b_end', titleField: 'block_name' };
+const FLAT = { startDateField: 'f_start', endDateField: 'f_end', titleField: 'flat_name' };
+
+function bothSpellings(extra: Record
= {}) {
+ return {
+ type: 'object-gantt',
+ ...FLAT,
+ gantt: { ...BLOCK },
+ data: { provider: 'value', items: INLINE },
+ ...extra,
+ } as any;
+}
+
+async function rendered(schema: any) {
+ const { container } = render();
+ const el = () => container.querySelector('[data-testid="gantt-view"]') as HTMLElement;
+ await waitFor(() => expect(el()?.getAttribute('data-count')).toBe('1'));
+ return {
+ titles: el().getAttribute('data-titles'),
+ starts: el().getAttribute('data-starts'),
+ };
+}
+
+let warn: ReturnType;
+beforeEach(() => {
+ warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+afterEach(() => {
+ warn.mockRestore();
+});
+
+/** Every recorded `console.warn` argument list. */
+const calls = (): unknown[][] => warn.mock.calls as unknown as unknown[][];
+/** …flattened to one searchable string. */
+const warnText = () => calls().map((c) => c.map(String).join(' ')).join('\n');
+const shadowWarnings = () =>
+ calls().filter((c) => String(c[0]).includes('so these top-level keys are'));
+
+describe('precedence: the `gantt` block outranks the flat spelling (objectui#6469)', () => {
+ it('renders the BLOCK values when a node carries both spellings', async () => {
+ // ⛔ The direction pin. Before the flip this same fixture rendered
+ // `FLAT Alpha` / 2024-03-01 — the flat branch returned before the block was
+ // ever read. Reverting `getGanttConfig`'s branch order turns this red.
+ const { titles, starts } = await rendered(bothSpellings({ objectName: 'precedence_a' }));
+ expect(titles).toBe('BLOCK Alpha');
+ expect(starts).toBe('2024-01-01');
+ });
+
+ it('still reads the flat spelling when there is no `gantt` block — the flatten product is unaffected', async () => {
+ const { titles, starts } = await rendered({
+ type: 'object-gantt',
+ ...FLAT,
+ data: { provider: 'value', items: INLINE },
+ objectName: 'precedence_b',
+ } as any);
+ expect(titles).toBe('FLAT Alpha');
+ expect(starts).toBe('2024-03-01');
+ });
+});
+
+describe('the losing face is NAMED, not dropped silently (objectui#6469)', () => {
+ it('names every shadowed top-level key in the dev-mode warning', async () => {
+ await rendered(
+ bothSpellings({ objectName: 'named_keys', colorField: 'flat_color', capacity: 3 }),
+ );
+
+ const hits = shadowWarnings();
+ expect(hits).toHaveLength(1);
+ const text = String(hits[0][0]);
+
+ // The keys this node actually shadows — the three flat `FLAT` keys plus the
+ // two extras. Each is asserted BY NAME: a warning that merely says "some
+ // keys were ignored" is the silence this card exists to end.
+ for (const key of ['startDateField', 'endDateField', 'titleField', 'colorField', 'capacity']) {
+ expect(text).toContain(`\`${key}\``);
+ }
+ // …and it does not invent keys the node never carried.
+ expect(text).not.toContain('`progressField`');
+ expect(text).not.toContain('`assigneeField`');
+ // The node-level keys that are NOT part of the flat gantt-config face must
+ // never be named: `objectName` / `data` / `type` are read straight off the
+ // schema and are unaffected by which config face wins.
+ expect(text).not.toContain('`objectName`');
+ expect(text).not.toContain('`data`');
+ });
+
+ it('says which face won and how to fix it', async () => {
+ await rendered(bothSpellings({ objectName: 'message_shape' }));
+ const text = String(shadowWarnings()[0][0]);
+ expect(text).toContain('[ObjectGantt]');
+ expect(text).toContain('`gantt` block');
+ expect(text).toContain('IGNORED');
+ expect(text).toContain('objectui#6469');
+ });
+
+ it('does NOT fire on the ObjectView / ListView flatten product', async () => {
+ // The hot path: flat keys, no `gantt` key. Both flatteners emit exactly
+ // this shape, so a warning here would fire on every flattened gantt node in
+ // the product.
+ await rendered({
+ type: 'object-gantt',
+ ...FLAT,
+ progressField: 'progress',
+ dependenciesField: 'deps',
+ data: { provider: 'value', items: INLINE },
+ objectName: 'flatten_product',
+ } as any);
+ expect(shadowWarnings()).toHaveLength(0);
+ });
+
+ it('names GanttConfig keys hoisted beside a block, with no top-level date pair', async () => {
+ // The shape published authoring guidance actually produces: a `gantt` block
+ // plus GanttConfig keys hoisted to the top level as if they were node-level
+ // options. There is no top-level date pair, so this node took the BLOCK
+ // branch before the flip too — the hoisted keys were already inert, just
+ // silently. The warning is what changes for it.
+ await rendered({
+ type: 'object-gantt',
+ gantt: { ...BLOCK },
+ quickFilters: [{ field: 'status' }],
+ autoZoomToFilter: true,
+ data: { provider: 'value', items: INLINE },
+ objectName: 'hoisted_beside_block',
+ } as any);
+
+ const hits = shadowWarnings();
+ expect(hits).toHaveLength(1);
+ const text = String(hits[0][0]);
+ expect(text).toContain('`quickFilters`');
+ expect(text).toContain('`autoZoomToFilter`');
+ });
+
+ it('does NOT fire for a block with no flat keys beside it', async () => {
+ await rendered({
+ type: 'object-gantt',
+ gantt: { ...BLOCK },
+ data: { provider: 'value', items: INLINE },
+ objectName: 'block_only',
+ } as any);
+ expect(shadowWarnings()).toHaveLength(0);
+ });
+
+ it('warns ONCE per distinct shadowing, not once per render', async () => {
+ const schema = bothSpellings({ objectName: 'warn_once' });
+ await rendered(schema);
+ await rendered(schema);
+ await rendered(schema);
+ expect(shadowWarnings()).toHaveLength(1);
+ });
+
+ it('still reports an incomplete winning block through the existing safeParse warning', async () => {
+ // The gantt-specific consequence of block-wins, stated out loud: the spec's
+ // `GanttConfigSchema` REQUIRES startDateField/endDateField/titleField
+ // (`ObjectMapConfigSchema`, the map case this ruling is inherited from,
+ // requires nothing), so an INCOMPLETE block outranks a complete flat
+ // spelling. The block is taken whole — merging the flat keys in would be
+ // the lenient consumer fallback AGENTS.md #0.1 forbids — so the author is
+ // told twice instead: the config is invalid, AND these flat keys lost.
+ render(
+ ,
+ );
+ await waitFor(() => expect(warnText()).toContain('Invalid gantt configuration'));
+ expect(shadowWarnings()).toHaveLength(1);
+ expect(String(shadowWarnings()[0][0])).toContain('`startDateField`');
+ });
+});
+
+describe('the named key set cannot drift from `GanttConfig` (objectui#6469)', () => {
+ it('covers the spec-modelled half by derivation and the extensions by list', () => {
+ // Runtime half: the list is built from `GanttConfigSchema.shape`, so a key
+ // added to the spec arrives here without a second edit.
+ expect(FLAT_GANTT_CONFIG_KEYS).toContain('startDateField');
+ expect(FLAT_GANTT_CONFIG_KEYS).toContain('quickFilters');
+ // objectui's own members, lifted into `GanttConfig` by objectui#6472.
+ expect(FLAT_GANTT_CONFIG_KEYS).toContain('lockField');
+ expect(FLAT_GANTT_CONFIG_KEYS).toContain('timeSegments');
+ // The legacy singular alias the flat branch still reads.
+ expect(FLAT_GANTT_CONFIG_KEYS).toContain('dependencyField');
+ // No duplicates — the two halves must not overlap.
+ expect(new Set(FLAT_GANTT_CONFIG_KEYS).size).toBe(FLAT_GANTT_CONFIG_KEYS.length);
+ });
+});
+
+/**
+ * Compile-time coverage pin (`tsc -p tsconfig.test.json` type-checks this file).
+ * `never` exactly while every `GanttConfig` key — the one declaration both faces
+ * derive from — appears in `FLAT_GANTT_CONFIG_KEYS`. A `GanttConfig` key that
+ * neither `GanttConfigSchema.shape` nor `GANTT_CONFIG_EXTENSION_KEYS` models
+ * makes this line fail to compile, NAMING the missing key.
+ */
+type AssertNever = T;
+export type UncoveredGanttConfigKey = AssertNever<
+ Exclude
+>;
diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx
index e886e376d..87f9de182 100644
--- a/packages/plugin-gantt/src/ObjectGantt.tsx
+++ b/packages/plugin-gantt/src/ObjectGantt.tsx
@@ -298,15 +298,180 @@ function extractServerMessage(err: unknown): string | null {
return null;
}
+/**
+ * Dev-only guard for the authoring diagnostics below. Mirrors `plugin-map`'s
+ * (`ObjectMap.tsx`): the warnings are feedback for whoever wrote the schema, and
+ * a production bundle should not pay for them.
+ */
+const isDev = (): boolean =>
+ (globalThis as { process?: { env?: Record } }).process?.env
+ ?.NODE_ENV !== 'production';
+
+/**
+ * `keyof T` with the string / number INDEX SIGNATURE stripped out.
+ *
+ * Load-bearing, not tidiness. `GanttConfig` derives from the spec's
+ * `GanttConfigSchema`, which carries an index signature, so a bare
+ * `keyof GanttConfig` widens to `string` — and every guard written against it
+ * (the `satisfies` below, the coverage pin in
+ * `ObjectGantt.blockPrecedence.test.tsx`) then constrains NOTHING while looking
+ * exactly like a guard that does. That is the same blind instrument
+ * objectui#6051's declaration pin records: an index signature absorbs precisely
+ * the evidence a type annotation would have produced. Measured here — the pin
+ * came back `string` before this alias existed.
+ */
+type KnownKeys = keyof {
+ [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
+};
+
+/** The DECLARED members of `GanttConfig` — its index signature removed. */
+export type KnownGanttConfigKey = KnownKeys;
+
+/**
+ * objectui's own `GanttConfig` members — the ten the spec's `GanttConfigSchema`
+ * does not model. They lived in this file's private `GanttConfigEx` until
+ * objectui#6472 lifted them into `@object-ui/types`, which is what makes
+ * `GanttConfig` the single declaration BOTH faces derive from.
+ *
+ * Listed here rather than derived because the runtime object that models them
+ * (`GanttConfigExtensionFields` in `@object-ui/types/zod`) is module-private
+ * there. `satisfies` keeps every entry a real `GanttConfig` key, and the
+ * coverage pin in `ObjectGantt.blockPrecedence.test.tsx` fails to compile if
+ * `GanttConfig` grows a key that neither source models.
+ */
+const GANTT_CONFIG_EXTENSION_KEYS = [
+ 'borderColorField',
+ 'lockField',
+ 'objectField',
+ 'summaryExtent',
+ 'defaultCollapsedDepth',
+ 'dependencyTypes',
+ 'timeZone',
+ 'exportFileName',
+ 'interactions',
+ 'timeSegments',
+] as const satisfies readonly KnownGanttConfigKey[];
+
+/**
+ * The FLAT spelling of `GanttConfig`'s keys — what `getGanttConfig`'s flat
+ * branch reads, and what `ObjectView` / `ListView` EMIT.
+ *
+ * Both flatteners build an `object-gantt` schema by spreading `options.gantt`'s
+ * CONTENTS at the top level (`plugin-view/src/ObjectView.tsx` `case 'gantt'`,
+ * `plugin-list/src/ListView.tsx` `case 'gantt'`); the product carries these keys
+ * and NO `gantt` key at all. That is an internal transport form, not a second
+ * authoring surface — and it is why the precedence flip below strands neither
+ * producer.
+ *
+ * The spec-modelled half is DERIVED from `GanttConfigSchema` — the same zod
+ * object the block branch validates against — so a key added to the spec reaches
+ * the shadow diagnostic without a second edit (the discipline
+ * `FLAT_MAP_CONFIG_KEYS` set in objectui#5177). `dependencyField` is the legacy
+ * singular alias the flat branch still reads beside `dependenciesField`; it is
+ * not a `GanttConfig` key, so it is named on its own.
+ */
+export const FLAT_GANTT_CONFIG_KEYS = [
+ ...(Object.keys(GanttConfigSchema.shape) as (keyof typeof GanttConfigSchema.shape)[]),
+ ...GANTT_CONFIG_EXTENSION_KEYS,
+ 'dependencyField' as const,
+];
+
+/**
+ * Warn once per distinct shadowing, not once per evaluation: `getGanttConfig`
+ * runs on every render of the chart (hover, zoom, quick-filter changes all
+ * re-render it), and a warning that floods the console is a warning that gets
+ * muted. Same discipline as `plugin-map`'s `warnedShadowedFlatKeys`.
+ */
+const warnedShadowedFlatGanttKeys = new Set();
+
+/**
+ * The `gantt` block won and the flat top-level keys alongside it were ignored —
+ * say which ones, in dev.
+ *
+ * Silence is what the precedence rule costs if it is not diagnosed: two
+ * spellings of ONE vocabulary (objectui#6051 proved they are one — both derive
+ * from `GanttConfig`) in a single schema, one of them inert. The ruling picks
+ * the author's block over the flatten product deliberately — maintainer on
+ * objectui#5018 (2026-08-17) for `plugin-map`, inherited here by objectui#6469 —
+ * so the diagnostic names what was dropped instead of leaving the author to
+ * infer it from a chart that renders the other spelling's values.
+ *
+ * It cannot fire on the ordinary ObjectView / ListView path, and that matters
+ * more here than it did for the map: the flat branch is the HOT path for gantt,
+ * because a hand-authored `gantt` block reaching this component through either
+ * view layer has already been flattened before `getGanttConfig` sees it. Both
+ * flatteners emit the flat keys and NO `gantt` key, so this function's block
+ * branch — the only caller of this warning — is not even reached for their
+ * output. Reaching it means one schema carries both spellings, which is exactly
+ * the case the flip changes.
+ */
+function warnOnShadowedFlatGanttKeys(schema: ObjectGanttSchema): void {
+ if (!isDev()) return;
+
+ const shadowed = FLAT_GANTT_CONFIG_KEYS.filter(
+ (key) => (schema as Record)[key] !== undefined,
+ );
+ if (shadowed.length === 0) return;
+
+ const memo = `${schema.type ?? 'gantt'}::${schema.objectName ?? ''}::${shadowed.join(',')}`;
+ if (warnedShadowedFlatGanttKeys.has(memo)) return;
+ warnedShadowedFlatGanttKeys.add(memo);
+
+ console.warn(
+ '[ObjectGantt] The `gantt` block configures this chart, so these top-level keys are ' +
+ `IGNORED: ${shadowed.map((k) => `\`${k}\``).join(', ')}. The \`gantt\` block is the ` +
+ 'authoring shape; the flat top-level spelling is the internal form ObjectView/ListView ' +
+ 'produce when they flatten `options.gantt`, and what the author wrote outranks it. The ' +
+ 'block is taken WHOLE — the flat keys are not merged into it — so move anything you ' +
+ 'still need into `gantt`, or drop the `gantt` block. objectui#6469.',
+ );
+}
+
/**
* Helper to get gantt configuration from schema
+ *
+ * PRECEDENCE (objectui#6469, inheriting the maintainer ruling on objectui#5018,
+ * 2026-08-17): the `gantt` block is checked FIRST and wins outright; the
+ * flattened top-level spelling is consulted only when no `gantt` block is
+ * present. This REVERSES the pre-#6469 order, under which the flat branch
+ * returned early and every key inside an authored `gantt` block was discarded
+ * with no diagnostic at all. `plugin-map` had the identical two-faces shape
+ * ruled the other way (PR #5156); gantt's answer had never been ruled — it was
+ * just what the early `return` happened to do.
+ *
+ * Safe for the producer path: neither flattener emits a `gantt` key, so their
+ * output still takes branch 2 exactly as before — see `FLAT_GANTT_CONFIG_KEYS`.
+ *
+ * The block is taken WHOLE, not merged over the flat keys. That is the ruling's
+ * shape ("what the author wrote outranks it"), and merging would be the lenient
+ * consumer fallback AGENTS.md #0.1 forbids. One consequence is gantt-specific
+ * and worth stating, because the map case cannot produce it: the spec's
+ * `GanttConfigSchema` REQUIRES `startDateField` / `endDateField` / `titleField`
+ * (`ObjectMapConfigSchema` requires nothing), so an INCOMPLETE block now
+ * outranks a complete flat spelling and yields an incomplete config. Both
+ * diagnostics fire on that node — `Invalid gantt configuration` from the
+ * `safeParse` below, and the shadow warning naming the flat keys that lost.
*/
function getGanttConfig(schema: ObjectGanttSchema): GanttConfigEx | null {
- let config: GanttConfigEx | null = null;
+ // 1. The `gantt` block (the ObjectGridSchema-style shape) — the authoring
+ // face, and the winner whenever it is present.
+ if (schema.gantt) {
+ const config = schema.gantt as GanttConfigEx;
+ const result = GanttConfigSchema.safeParse(config);
+ if (!result.success) {
+ console.warn(`[ObjectGantt] Invalid gantt configuration:`, result.error.format());
+ }
+ warnOnShadowedFlatGanttKeys(schema);
+ return config;
+ }
- // 1. Check top-level properties (the flattened ObjectGanttSchema style)
+ // 2. The internal flat form — the ObjectView / ListView flatten product.
+ // Taken only when BOTH date fields are present, unchanged by the flip; a
+ // partial flat spelling still falls through to `null`. Deliberately NOT
+ // `safeParse`d, also unchanged: this branch never validated, and adding
+ // validation to it is a separate question from precedence.
if (schema.startDateField && schema.endDateField) {
- config = {
+ return {
startDateField: schema.startDateField,
endDateField: schema.endDateField,
titleField: schema.titleField || 'name',
@@ -337,22 +502,8 @@ function getGanttConfig(schema: ObjectGanttSchema): GanttConfigEx | null {
timeZone: schema.timeZone,
dependencyTypes: schema.dependencyTypes,
};
- return config;
- }
-
- // 2. Check schema.gantt (the block face, ObjectGridSchema style)
- if (schema.gantt) {
- config = schema.gantt as GanttConfigEx;
}
- if (config) {
- const result = GanttConfigSchema.safeParse(config);
- if (!result.success) {
- console.warn(`[ObjectGantt] Invalid gantt configuration:`, result.error.format());
- }
- return config;
- }
-
return null;
}
diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts
index b84200d08..573e194df 100644
--- a/packages/types/src/objectql.ts
+++ b/packages/types/src/objectql.ts
@@ -2394,10 +2394,11 @@ export interface ObjectGanttSchema extends BaseSchema {
// ── The flattened `GanttConfig` face (objectui#6051) ────────────────────────
//
- // `getGanttConfig` (`plugin-gantt/src/ObjectGantt.tsx`) has two branches. When
- // `startDateField` AND `endDateField` are present at the TOP LEVEL it builds the
- // config from top-level keys and RETURNS EARLY; otherwise it reads the `gantt`
- // block declared below. The keys of the first branch were declared by neither
+ // `getGanttConfig` (`plugin-gantt/src/ObjectGantt.tsx`) has two branches. The
+ // `gantt` block wins whenever it is present (objectui#6469); this flat face is
+ // read only when there is no block, and then only when `startDateField` AND
+ // `endDateField` are both present at the TOP LEVEL. The keys of the flat branch
+ // were declared by neither
// this interface nor `ObjectGridSchema`: they were reachable only through
// `BaseSchema`'s `[key: string]: any`, so `schema.colorField` type-checked as
// `any` with no cast anywhere to grep for. That is why the census behind this
@@ -2409,10 +2410,12 @@ export interface ObjectGanttSchema extends BaseSchema {
// spelling. All are optional, matching the renderer: the flat branch reads each
// key bare and forwards `undefined` unchanged.
//
- // ⚠️ WHICH face wins is unchanged here and is not this card's question: the flat
- // branch is checked first and returns early, so a node carrying both spellings
- // renders the flat one. (`plugin-map` had the opposite precedence ruled on in
- // objectui#5018; no equivalent ruling exists for gantt.)
+ // ⚠️ WHICH face wins was NOT decided by objectui#6051, which declared these keys.
+ // It was settled afterwards by objectui#6469, inheriting the maintainer ruling on
+ // objectui#5018 (2026-08-17) that `plugin-map` shipped in PR #5156: the BLOCK
+ // wins, taken whole, and the shadowed flat keys are named in a dev-mode warning
+ // instead of being dropped silently. So a node carrying both spellings renders
+ // the `gantt` block's values — the reverse of the pre-#6469 order.
/** Record field carrying the bar fill colour. See {@link GanttConfig}. */
colorField?: GanttConfig['colorField'];
diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts
index 13559a758..e5f1c5ec1 100644
--- a/packages/types/src/zod/objectql.zod.ts
+++ b/packages/types/src/zod/objectql.zod.ts
@@ -698,8 +698,9 @@ export const ObjectGanttSchema = BaseSchema.extend({
readOnly: z.boolean().optional().describe('Disable every write path and lock the record drawer'),
mobileReadOnly: z.boolean().optional().describe('Auto read-only on narrow viewports — defaults ON, only an explicit false disables'),
// objectui#6051 — the FLATTENED `GanttConfig` face. `getGanttConfig` builds its
- // config from these top-level keys and returns early whenever `startDateField`
- // and `endDateField` are both present; nothing declared them, on either side,
+ // config from these top-level keys when the node carries no `gantt` block and
+ // `startDateField` / `endDateField` are both present — the block OUTRANKS this
+ // face (objectui#6469); nothing declared them, on either side,
// because `BaseSchema`'s index signature admits them untyped. Mirrored at the
// SAME requiredness as `../objectql.ts` (all optional) so the zod-mirror-parity
// ratchet stays at zero drift for this pair.