}) => {
+ composed.push(schema);
+ return {String((schema.items as unknown[])?.length ?? 0)}
;
+ },
+}));
+
+/**
+ * The REAL `resolveTimelineScale`, reached past the whole-module mock above.
+ * Asserting through it rather than reading the key directly is the point: it is
+ * the production read path, so a migration that renamed the key but left the
+ * value unreachable cannot pass.
+ */
+let resolveTimelineScale: (schema: { scale?: unknown }) => string;
+
+beforeAll(async () => {
+ const actual = await vi.importActual('./renderer');
+ resolveTimelineScale = actual.resolveTimelineScale;
+ expect(typeof resolveTimelineScale, 'could not reach the real resolveTimelineScale').toBe('function');
+});
+
+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,
+ }),
+ useObjectLabel: () => ({
+ fieldOptionLabel: (_o: string, _f: string, _v: string, fb: string) => fb,
+ translateOptions: (_o: string, _f: string, opts: unknown[]) => opts,
+ fieldLabel: (_o: string, _f: string, fb: string) => fb,
+ }),
+ };
+});
+
+const rows = [
+ { id: '1', name: 'Spring Launch', start_date: '2099-09-01', end_date: '2099-09-30' },
+ { id: '2', name: 'Summer Push', start_date: '2100-10-01', end_date: '2100-10-31' },
+];
+
+async function renderTimeline(schema: Record) {
+ const props = { schema, data: rows } as unknown as React.ComponentProps;
+ render();
+ // Waiting on the STUB's own marker proves the stand-in actually installed. An
+ // inert `vi.mock` reports the same green a working one does
+ // (`scripts/check-vi-mock-specifiers.mjs`), so the capture is asserted, never assumed.
+ await waitFor(() => expect(screen.getAllByTestId('captured').length).toBeGreaterThan(0));
+ expect(composed.length, 'the TimelineRenderer stand-in never ran — the mock is inert').toBeGreaterThan(0);
+ return composed[composed.length - 1];
+}
+
+/**
+ * `variant: 'vertical'`, deliberately, even though the axis it carries is a
+ * GANTT concern. The composition under test is variant-independent —
+ * `ObjectTimeline` writes the resolved axis unconditionally — and rendering the
+ * gantt variant here would exercise an unrelated pre-existing crash:
+ * `calculateDateRange` reads gantt ROW shape (`row.items[].startDate`) while
+ * `ObjectTimeline` composes feed items, so `Math.min()` over an empty list
+ * yields `Infinity` and `new Date(Infinity).toISOString()` throws
+ * `RangeError: Invalid time value`. That is filed separately; pinning it here
+ * would couple this card's pin to a defect it does not own.
+ */
+const BASE = {
+ type: 'timeline',
+ variant: 'vertical',
+ objectName: 'campaign',
+ timeline: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' },
+};
+
+beforeEach(() => {
+ composed.length = 0;
+});
+
+describe('ObjectTimeline composes the axis under `scale` (objectui#6355)', () => {
+ it('emits the spec `timeline.scale` as `scale`, and the resolver reads it', async () => {
+ const schema = await renderTimeline({ ...BASE, timeline: { ...BASE.timeline, scale: 'year' } });
+
+ expect(schema.timeScale, 'ObjectTimeline still composes the RETIRED alias').toBeUndefined();
+ expect(schema.scale).toBe('year');
+ // The read path, not the key name: these must agree or the migration is half done.
+ expect(resolveTimelineScale(schema)).toBe('year');
+ });
+
+ it('emits a flat `schema.scale` the same way', async () => {
+ const schema = await renderTimeline({ ...BASE, scale: 'quarter' });
+
+ expect(schema.timeScale).toBeUndefined();
+ expect(resolveTimelineScale(schema)).toBe('quarter');
+ });
+
+ it('lets `timeline.scale` win over a flat `scale`, as the code intends', async () => {
+ // Under the alias this was INVERTED and nothing noticed: ObjectTimeline
+ // resolved `timelineConfig.scale ?? schema.scale` and wrote the winner to
+ // `timeScale`, but the spread carried the flat `scale` through untouched and
+ // `resolveTimelineScale`'s `scale ?? timeScale` ordering then preferred it.
+ // Writing the resolved value under `scale` after the spread is what makes
+ // the stated precedence real.
+ const schema = await renderTimeline({
+ ...BASE,
+ scale: 'day',
+ timeline: { ...BASE.timeline, scale: 'year' },
+ });
+
+ expect(resolveTimelineScale(schema)).toBe('year');
+ });
+
+ it('composes no axis at all when none is configured', async () => {
+ // Counter-probe: the assertions above must be reading a value this component
+ // actually put there, not one it always writes.
+ const schema = await renderTimeline({ ...BASE });
+
+ expect(schema.scale).toBeUndefined();
+ expect(schema.timeScale).toBeUndefined();
+ expect(resolveTimelineScale(schema)).toBe('month');
+ });
+});
diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx
index f28b5f0c9e..d0604c86b3 100644
--- a/packages/plugin-timeline/src/ObjectTimeline.tsx
+++ b/packages/plugin-timeline/src/ObjectTimeline.tsx
@@ -401,8 +401,16 @@ export const ObjectTimeline: React.FC = ({
...schema,
items: effectiveItems || [],
className: className || schema.className,
- // Map spec 'scale' to renderer 'timeScale' (used by gantt variant)
- ...(resolvedScale ? { timeScale: resolvedScale } : {}),
+ // Emit the resolved axis under the canonical `scale` (used by the gantt
+ // variant). This used to write the `timeScale` alias, which objectui#6355
+ // retired: leaving it would have made EVERY object-bound gantt fall
+ // through to the `month` default the moment `resolveTimelineScale` stopped
+ // reading the alias — silently, since this is a composed schema no author
+ // ever sees. Writing `scale` after the spread also restores the priority
+ // the line above intends: a `timelineConfig.scale` now actually beats a
+ // flat `schema.scale`, where under the alias the resolver's `scale ??
+ // timeScale` ordering let the flat key win.
+ ...(resolvedScale ? { scale: resolvedScale } : {}),
onItemClick: (item: any) => {
const record = item._data || item;
navigation.handleClick(record);
diff --git a/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx
new file mode 100644
index 0000000000..e5851f3188
--- /dev/null
+++ b/packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx
@@ -0,0 +1,70 @@
+/**
+ * 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.
+ */
+
+/**
+ * The in-repo authors of the gantt axis are migrated off the retired
+ * `timeScale` alias (objectui#6355).
+ *
+ * The ruling retires the alias in one PR, migration included, so this repo must
+ * not keep authoring the spelling it just made unauthorable. Three writers
+ * existed on `origin/main`:
+ *
+ * 1. the schema-catalog fixture `gantt-style-timeline.json` — pinned in
+ * `@object-ui/types`'
+ * `__tests__/timeline-catalog-fixture-migrated.test.ts`, which is where
+ * node types and the Zod validator are both available (this package's test
+ * tsconfig carries no node types);
+ * 2. the registration's own `examples.gantt` block (`../renderer`);
+ * 3. `ObjectTimeline`, which COMPOSES a schema for `TimelineRenderer` and
+ * wrote the resolved axis under the alias. That one is invisible to a
+ * grep of authored metadata — no author ever sees that object — and it is
+ * the one that would have silently reverted every object-bound gantt to
+ * the `month` default the moment the fallback read went. It is pinned in
+ * `timeline-object-scale-composition.test.tsx`.
+ *
+ * The point of pinning 1 and 2 is not that the string changed. It is that these
+ * documents still RESOLVE to the bucket they declare. A migration that renamed
+ * the key but left the value unreachable would look identical in a diff and
+ * would render a `month` axis for a document that says `month` — green for the
+ * wrong reason. So each assertion below goes through `resolveTimelineScale`,
+ * the real read path, and the fixture is additionally parsed by the published
+ * validator that now REFUSES the old spelling.
+ */
+import { describe, it, expect } from 'vitest';
+import { ComponentRegistry } from '@object-ui/core';
+import { TimelineSchema } from '@object-ui/types/zod';
+import { resolveTimelineScale } from '../renderer';
+// Importing the package entry performs the registration, exactly as a host does.
+import '../index';
+
+describe('in-repo gantt authors use the canonical `scale` (objectui#6355)', () => {
+ it("the registration's own `examples.gantt` authors `scale`, and it resolves", () => {
+ // Read back from the registry rather than restated here, so this cannot
+ // drift from the declaration it is pinning.
+ const meta = ComponentRegistry.getMeta('plugin-timeline:timeline');
+ const gantt = (meta?.examples as Record> | undefined)?.gantt;
+
+ expect(gantt, 'the registration no longer publishes a `gantt` example').toBeDefined();
+ expect(gantt!.variant).toBe('gantt');
+ expect(gantt!.timeScale, "the registration's gantt example still authors the RETIRED alias").toBeUndefined();
+ expect(gantt!.scale).toBe('month');
+ expect(resolveTimelineScale(gantt!)).toBe('month');
+
+ const parsed = TimelineSchema.safeParse({ type: 'timeline', ...gantt });
+ expect(parsed.success ? null : parsed.error.issues).toBe(null);
+ });
+
+ it('the designer no longer offers the retired alias as an input', () => {
+ const meta = ComponentRegistry.getMeta('plugin-timeline:timeline');
+ const inputs = (meta?.inputs ?? []) as Array<{ name?: string }>;
+ const names = inputs.map((i) => i.name);
+
+ expect(names, 'the deprecated `timeScale` control is still offered').not.toContain('timeScale');
+ expect(names, 'the canonical `scale` control must remain').toContain('scale');
+ });
+});
diff --git a/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts b/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts
index b4dd49d2c4..6ad340a8ba 100644
--- a/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts
+++ b/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts
@@ -13,6 +13,12 @@
* spec's `scale` (`TimelineConfigSchema`) was ignored for ALL six values;
* and the header generator only knew month/week/day, so `hour` / `quarter` /
* `year` produced zero header columns — a blank gantt axis.
+ *
+ * #2942 fixed the first drift by preferring `scale` and keeping `timeScale` as
+ * a fallback. objectui#6355 finishes it: the alias is RETIRED, so `scale` is
+ * the single axis spelling here. The refusal that keeps that retirement from
+ * being a silent revert is pinned in `@object-ui/types`
+ * (`__tests__/timeline-timescale-retired.test.ts`).
*/
import { describe, it, expect } from 'vitest';
import { TimelineConfigSchema } from '@objectstack/spec/ui';
@@ -43,21 +49,43 @@ describe('timeline covers the spec scale vocabulary', () => {
});
});
-describe('resolveTimelineScale honors the spec key first', () => {
+describe('resolveTimelineScale reads the spec key, and only it', () => {
it('reads the spec `scale` key', () => {
expect(resolveTimelineScale({ scale: 'quarter' })).toBe('quarter');
});
- it('keeps the legacy `timeScale` dialect for stored JSON', () => {
- expect(resolveTimelineScale({ timeScale: 'day' })).toBe('day');
+ it("absent/unknown values keep the renderer's historical month default", () => {
+ expect(resolveTimelineScale({})).toBe('month');
+ expect(resolveTimelineScale({ scale: 'fortnight' })).toBe('month');
});
- it('the spec key wins when both are present', () => {
- expect(resolveTimelineScale({ scale: 'year', timeScale: 'day' })).toBe('year');
+ it('no longer reads the RETIRED `timeScale` alias (objectui#6355)', () => {
+ // This replaces two assertions that pinned the alias branch — "keeps the
+ // legacy timeScale dialect" and "the spec key wins when both are present".
+ // Neither could survive the retirement honestly: the first pinned exactly
+ // the branch being deleted, and the second would have kept passing for a
+ // NEW reason (the alias ignored outright rather than losing a precedence
+ // contest), which is a pin that reads green while measuring nothing.
+ //
+ // The reversion below is REAL and is the accepted cost of the ruling
+ // (2026-08-27: immediate retirement, no phased window, startup stage). It
+ // is not left silent — that is the whole point of the retirement's other
+ // half. `@object-ui/types` tombstones the key on BOTH surfaces
+ // (`TimelineSchema.timeScale?: never` and the Zod twin's `z.never()`), so a
+ // document that still spells it is refused at the authoring boundary before
+ // it can ever reach this resolver and quietly re-bucket the chart. The pin
+ // for that refusal lives in
+ // `packages/types/src/__tests__/timeline-timescale-retired.test.ts`.
+ // A stored document exactly as it sits on disk today: axis spelled the old way.
+ const storedDocument: Record = { variant: 'gantt', timeScale: 'day' };
+ expect(resolveTimelineScale(storedDocument)).toBe('month');
});
- it("absent/unknown values keep the renderer's historical month default", () => {
- expect(resolveTimelineScale({})).toBe('month');
- expect(resolveTimelineScale({ scale: 'fortnight' })).toBe('month');
+ it('the retired alias cannot override an authored `scale`', () => {
+ // Counter-probe: the canonical key still wins, and still works, on a
+ // document that carries both. Without this the assertion above is satisfied
+ // by a resolver that returns 'month' for everything.
+ const bothSpellings: Record = { variant: 'gantt', scale: 'year', timeScale: 'day' };
+ expect(resolveTimelineScale(bothSpellings)).toBe('year');
});
});
diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx
index 0ddf84dd9d..d3d718f7f5 100644
--- a/packages/plugin-timeline/src/renderer.tsx
+++ b/packages/plugin-timeline/src/renderer.tsx
@@ -46,16 +46,30 @@ export const TIMELINE_SCALES: ReadonlySet = new Set([
]);
/**
- * Resolve the axis scale for the gantt variant. The spec key is `scale`;
- * `timeScale` is this renderer's pre-spec dialect, kept for stored JSON —
- * before #2942 ONLY `timeScale` was read, so every spec-authored `scale`
- * (all six values) was silently ignored. An absent/unknown value keeps the
- * renderer's historical `month` default. The `vertical` / `horizontal`
- * variants are sequential event feeds with no time axis, so `scale` has
- * nothing to bucket there by construction.
+ * Resolve the axis scale for the gantt variant. `scale` is the ONLY axis key —
+ * it is `@objectstack/spec` `ui/TimelineConfig.json`'s spelling.
+ *
+ * The `timeScale` alias this used to fall back to (`scale ?? timeScale`) is
+ * RETIRED (objectui#6355, maintainer ruling 2026-08-27: immediate retirement,
+ * no phased window, while the project is at startup stage). It was this
+ * renderer's pre-spec dialect: before #2942 ONLY `timeScale` was read, so every
+ * spec-authored `scale` was silently ignored — this function was the fix, and
+ * dropping the alias half completes it.
+ *
+ * A document that still spells `timeScale` no longer reaches this function with
+ * an axis, and would fall to the `month` default below. That reversion is NOT
+ * left silent: `@object-ui/types` tombstones the key on both halves
+ * (`TimelineSchema.timeScale?: never` and the Zod twin's `z.never()`), so the
+ * retired spelling is refused at the authoring boundary rather than quietly
+ * re-bucketing the chart. The tombstone is why this deletion is safe; the two
+ * ship together.
+ *
+ * An absent/unknown value keeps the renderer's historical `month` default. The
+ * `vertical` / `horizontal` variants are sequential event feeds with no time
+ * axis, so `scale` has nothing to bucket there by construction.
*/
-export function resolveTimelineScale(schema: { scale?: unknown; timeScale?: unknown }): string {
- const raw = schema.scale ?? schema.timeScale;
+export function resolveTimelineScale(schema: { scale?: unknown }): string {
+ const raw = schema.scale;
return typeof raw === 'string' && TIMELINE_SCALES.has(raw) ? raw : 'month';
}
@@ -411,11 +425,11 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
const minDate = schema.minDate || dateRange.minDate;
const maxDate = schema.maxDate || dateRange.maxDate;
- // Generate time scale headers — the spec `scale` key drives this
- // (legacy `timeScale` kept for stored JSON); every spec scale produces
- // a header row (#2942).
+ // Generate time scale headers — the spec `scale` key is the only axis
+ // spelling (the `timeScale` alias is retired, objectui#6355); every spec
+ // scale produces a header row (#2942).
const timeHeaders = generateTimeScaleHeaders(
- resolveTimelineScale(schema as { scale?: unknown; timeScale?: unknown }),
+ resolveTimelineScale(schema as { scale?: unknown }),
minDate,
maxDate,
displayLocale,
@@ -544,7 +558,8 @@ ComponentRegistry.register(
defaultValue: 'short',
},
// The designer's axis key is `scale` — the spec's spelling
- // (`ui/TimelineConfig.json`) and the one `resolveTimelineScale` prefers.
+ // (`ui/TimelineConfig.json`) and, since objectui#6355 retired the
+ // `timeScale` alias, the only one `resolveTimelineScale` reads.
// It offers all six buckets: `hour` / `quarter` / `year` have rendered
// correctly since #2942 but were offered by neither the designer nor the
// exported type, so they were authorable and undiscoverable (objectui#6170).
@@ -555,21 +570,6 @@ ComponentRegistry.register(
label: 'Time Scale (Gantt only)',
defaultValue: 'month',
},
- // Kept so a stored `timeScale` still round-trips through the designer.
- // Deprecated in favour of `scale`; retiring the alias is routed separately
- // (objectui#6170 maintainer ruling 2026-08-25). `ComponentInput` has no
- // `deprecated` slot and no index signature, so the notice lives in
- // `description` — this package's stated ceiling for anything the coarse
- // `type` cannot express.
- {
- name: 'timeScale',
- type: 'enum',
- enum: [...TIMELINE_SCALES],
- label: 'Time Scale (Gantt only)',
- description:
- 'DEPRECATED — use `scale`, which @objectstack/spec owns and this renderer reads first. Kept so stored JSON keeps working.',
- advanced: true,
- },
{
name: 'rowLabel',
type: 'string',
@@ -676,7 +676,7 @@ ComponentRegistry.register(
gantt: {
variant: 'gantt',
dateFormat: 'short',
- timeScale: 'month',
+ scale: 'month',
rowLabel: 'Projects',
items: [
{
diff --git a/packages/types/src/__tests__/timeline-catalog-fixture-migrated.test.ts b/packages/types/src/__tests__/timeline-catalog-fixture-migrated.test.ts
new file mode 100644
index 0000000000..a4fbde01b7
--- /dev/null
+++ b/packages/types/src/__tests__/timeline-catalog-fixture-migrated.test.ts
@@ -0,0 +1,76 @@
+/**
+ * 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.
+ */
+
+/**
+ * The shipped schema-catalog gantt fixture is migrated off the retired
+ * `timeScale` alias (objectui#6355).
+ *
+ * The ruling retires the alias and migrates the in-repo authors in one PR, so
+ * this repo must not keep shipping a catalog entry spelling the key it just made
+ * unauthorable — that entry is a worked example users copy.
+ *
+ * Lives in `@object-ui/types` because it needs both halves at once: node types
+ * to read the fixture off disk (`tsconfig.test.json` declares them, and other
+ * tests here already read files) and the published Zod validator to judge it.
+ * `packages/plugin-timeline`'s test tsconfig carries no node types, and
+ * `scripts/__tests__` — the repo's other file-reading test home — cannot resolve
+ * `@object-ui/types/zod` at all, since `scripts/` is not a workspace package and
+ * has no dependency edge to it. Its siblings — the registration's `examples.gantt` block and the
+ * designer inputs — are pinned in
+ * `packages/plugin-timeline/src/__tests__/timeline-inrepo-authors-migrated.test.tsx`,
+ * and the refusal that makes the retirement audible is pinned in
+ * `packages/types/src/__tests__/timeline-timescale-retired.test.ts`.
+ *
+ * The assertion is not that the string changed. The fixture is parsed by the
+ * published validator — the same one that now REFUSES its pre-migration form —
+ * so a rename that left the document invalid, or a value that stopped being
+ * reachable, fails here.
+ */
+import { describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { TimelineSchema } from '../zod/data-display.zod.js';
+
+const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
+const FIXTURE = path.join(
+ REPO_ROOT,
+ 'examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json',
+);
+
+describe('schema-catalog gantt fixture uses the canonical `scale` (objectui#6355)', () => {
+ it('the fixture this pin reads is actually on disk', () => {
+ // Asserted before anything reads it: a path that silently resolved to
+ // nothing would turn every assertion below into a vacuous pass.
+ expect(fs.existsSync(FIXTURE), `fixture not found at ${FIXTURE}`).toBe(true);
+ });
+
+ it('authors `scale`, not the retired alias, and still validates', () => {
+ const doc = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')) as Record;
+
+ expect(doc.variant, 'fixture is no longer the gantt one this pin was written for').toBe('gantt');
+ expect(doc.timeScale, 'the schema-catalog fixture still authors the RETIRED alias').toBeUndefined();
+ expect(doc.scale).toBe('month');
+
+ const parsed = TimelineSchema.safeParse(doc);
+ expect(parsed.success ? null : parsed.error.issues).toBe(null);
+ });
+
+ it('the pre-migration form of that same fixture is REFUSED', () => {
+ // Counter-probe, and the tightest statement of what the migration bought:
+ // this exact document with only the key renamed back does not parse. Without
+ // it, the assertion above would also pass against a validator that accepts
+ // both spellings.
+ const doc = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')) as Record;
+ const { scale, ...rest } = doc;
+ const preMigration = { ...rest, timeScale: scale };
+
+ const parsed = TimelineSchema.safeParse(preMigration);
+ expect(parsed.success, 'the retired spelling of the shipped fixture still parses').toBe(false);
+ });
+});
diff --git a/packages/types/src/__tests__/timeline-declared-keys.test.ts b/packages/types/src/__tests__/timeline-declared-keys.test.ts
index 114be7dca2..3117b694da 100644
--- a/packages/types/src/__tests__/timeline-declared-keys.test.ts
+++ b/packages/types/src/__tests__/timeline-declared-keys.test.ts
@@ -78,13 +78,25 @@ function unwrap(schema: any): any {
return cur;
}
-/** The eight keys this card declared, each with a value its declared type refuses. */
+/**
+ * The keys this card declared, each with a value its declared type refuses.
+ *
+ * `timeScale` was the eighth. objectui#6355 RETIRED it, so it is no longer a
+ * declared key carrying a vocabulary — it is a `?: never` / `z.never()`
+ * tombstone, for which "refuses a wrong-typed value" and "accepts a well-typed
+ * value" are the wrong assertions in both directions: the first would pass for
+ * a reason that has nothing to do with this card, and the second cannot pass at
+ * all. Its retirement has its own pin, `timeline-timescale-retired.test.ts`,
+ * which also
+ * carries the counter-probes. Removing it here rather than editing its value in
+ * place is deliberate: it is the fixture that pinned the branch that was
+ * deleted.
+ */
const DECLARED: ReadonlyArray = [
['variant', 'diagonal'],
['items', 'not-an-array'],
['dateFormat', 'medieval'],
['scale', 'fortnight'],
- ['timeScale', 'fortnight'],
['rowLabel', 5],
['minDate', 20240101],
['maxDate', 20241231],
@@ -134,7 +146,6 @@ describe('TimelineSchema — the eight presentational keys are declared (objectu
items: [{ label: 'Backend', items: [{ title: 'API', startDate: '2024-01-01', endDate: '2024-01-31' }] }],
dateFormat: 'iso',
scale: 'quarter',
- timeScale: 'month',
rowLabel: 'Projects',
minDate: '2024-01-01',
maxDate: '2024-12-31',
@@ -154,9 +165,10 @@ describe('TimelineSchema — the eight presentational keys are declared (objectu
});
describe('TimelineSchema — `scale` is canonical and shares the spec vocabulary', () => {
- // The renderer resolves `scale ?? timeScale` and accepts six values on either
- // (`resolveTimelineScale`, pinned against the spec by
- // `plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts`). This is
+ // The renderer resolves `scale` and accepts six values (`resolveTimelineScale`,
+ // pinned against the spec by
+ // `plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts`; the
+ // `timeScale` alias it used to also read is retired, objectui#6355). This is
// the third leg of that agreement: the exported TYPE offers the same six.
const specScales: string[] = unwrap(TimelineConfigSchema.shape.scale).options;
@@ -164,19 +176,22 @@ describe('TimelineSchema — `scale` is canonical and shares the spec vocabulary
expect(specScales, 'could not read TimelineConfigSchema.shape.scale options').not.toEqual([]);
});
- it('`scale` and the deprecated `timeScale` alias accept exactly the spec vocabulary', () => {
- for (const key of ['scale', 'timeScale'] as const) {
- for (const value of specScales) {
- const result = TimelineSchema.safeParse({ ...MINIMAL, [key]: value });
- expect(result.success, `${key} refused spec scale '${value}'`).toBe(true);
- }
+ it('`scale` accepts exactly the spec vocabulary — and is the only key that does', () => {
+ // It used to loop over `['scale', 'timeScale']`. The alias is RETIRED
+ // (objectui#6355) and now refuses every one of these values; that half is
+ // pinned in `timeline-timescale-retired.test.ts`.
+ for (const value of specScales) {
+ const result = TimelineSchema.safeParse({ ...MINIMAL, scale: value });
+ expect(result.success, `scale refused spec scale '${value}'`).toBe(true);
}
});
it('the registry `inputs` three-value timeScale enum is NOT the contract', () => {
- // Before this card the designer offered `timeScale: day | week | month` and
- // the type offered neither key. `hour` / `quarter` / `year` were authorable,
- // rendered correctly, and were undiscoverable from both surfaces.
+ // Before objectui#6170 the designer offered `timeScale: day | week | month`
+ // and the type offered neither key. `hour` / `quarter` / `year` were
+ // authorable, rendered correctly, and were undiscoverable from both
+ // surfaces. That designer input is gone entirely now — objectui#6355 retired
+ // the alias and dropped its control; `scale` offers all six.
for (const value of ['hour', 'quarter', 'year']) {
expect(TimelineSchema.safeParse({ ...MINIMAL, scale: value }).success, value).toBe(true);
}
@@ -233,8 +248,12 @@ describe('TimelineSchema (TS) — compile-time pin on the same keys', () => {
const dateFormat: TimelineSchemaTS['dateFormat'] = 'medieval';
// @ts-expect-error — `scale` is declared `TimelineScale | undefined`.
const scale: TimelineSchemaTS['scale'] = 'fortnight';
- // @ts-expect-error — `timeScale` is the deprecated alias, same six values.
- const timeScale: TimelineSchemaTS['timeScale'] = 'fortnight';
+ // `timeScale` used to sit here as the deprecated alias with the same six
+ // values. It is RETIRED (objectui#6355) and its directive would now hold for
+ // a different reason — `never` refuses `'fortnight'` the way it refuses
+ // every value — so keeping it here would read as vocabulary enforcement
+ // while measuring the tombstone. The tombstone has its own pin, with its own
+ // counter-probe: `timeline-timescale-retired.test.ts`.
// @ts-expect-error — `rowLabel` is declared `string | undefined`.
const rowLabel: TimelineSchemaTS['rowLabel'] = 5;
// @ts-expect-error — `minDate` is declared `string | undefined` (schemas are JSON).
@@ -247,8 +266,8 @@ describe('TimelineSchema (TS) — compile-time pin on the same keys', () => {
const position: TimelineSchemaTS['position'] = 'centre';
expect([
- variant, dateFormat, scale, timeScale, rowLabel, minDate, maxDate, orientation, position,
- ]).toHaveLength(9);
+ variant, dateFormat, scale, rowLabel, minDate, maxDate, orientation, position,
+ ]).toHaveLength(8);
});
it('accepts the well-typed value on every declared key', () => {
@@ -260,7 +279,6 @@ describe('TimelineSchema (TS) — compile-time pin on the same keys', () => {
items: [{ label: 'Backend', items: [{ title: 'API', startDate: '2024-01-01', endDate: '2024-01-31' }] }],
dateFormat: 'iso',
scale: 'quarter',
- timeScale: 'month',
rowLabel: 'Projects',
minDate: '2024-01-01',
maxDate: '2024-12-31',
diff --git a/packages/types/src/__tests__/timeline-timescale-retired.test.ts b/packages/types/src/__tests__/timeline-timescale-retired.test.ts
new file mode 100644
index 0000000000..8db87909f1
--- /dev/null
+++ b/packages/types/src/__tests__/timeline-timescale-retired.test.ts
@@ -0,0 +1,153 @@
+/**
+ * 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.
+ */
+
+/**
+ * Retirement pin — the `timeScale` alias is REFUSED, not silently defaulted
+ * (objectui#6355).
+ *
+ * ## The failure this pin exists to prevent
+ *
+ * `timeScale` was this renderer's pre-spec spelling of the gantt axis bucket.
+ * `scale` is canonical (objectui#6170 maintainer ruling 2026-08-25: it is
+ * `@objectstack/spec` `ui/TimelineConfig.json`'s axis key AND the renderer's
+ * preferred read), and objectui#6355's ruling (2026-08-27) retires the alias
+ * immediately, with no phased window.
+ *
+ * Dropping `resolveTimelineScale`'s `?? schema.timeScale` fallback is the whole
+ * behavioural change, and on its own it is the WRONG shape: a stored document
+ * that spells only `timeScale` would stop being read and fall through to the
+ * renderer's historical `month` default. The chart silently changes bucket and
+ * NOTHING errors — the same class of silent axis breakage objectui#2942 closed,
+ * running in the other direction.
+ *
+ * So the deliverable is not "`scale` works". It is: **an old-spelling document
+ * is refused loudly at the authoring boundary.** That is what the two halves of
+ * the tombstone buy, and it is what this file pins.
+ *
+ * ## Why the tombstone, and not simply deleting the key
+ *
+ * `BaseSchema` is `.passthrough()` on the Zod side and carries a
+ * `[key: string]: any` index signature on the TS side (objectui#5155 /
+ * objectui#6269 own that ceiling). An UNDECLARED key is therefore accepted by
+ * both halves, unvalidated. Deleting `timeScale` outright would hand the old
+ * spelling exactly the silent no-op this card exists to prevent: it would parse
+ * green, type-check green, and do nothing.
+ *
+ * Keeping the key declared as `?: never` / `z.never().optional()` is what makes
+ * the retirement audible. That is this package's tombstone convention —
+ * {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm`
+ * (objectui#4314) — and it is lockstep: both halves or neither, because either
+ * half alone leaves the other surface silently accepting the retired spelling.
+ * `absent` stays valid on both halves, so every document that never wrote the
+ * alias is untouched.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { TimelineSchema } from '../zod/data-display.zod.js';
+import type { TimelineSchema as TimelineSchemaTS } from '../data-display.js';
+
+/** The exact document class this card protects: gantt, axis spelled the OLD way, no `scale`. */
+const TIMESCALE_ONLY_DOCUMENT = {
+ type: 'timeline',
+ variant: 'gantt',
+ timeScale: 'day',
+ rowLabel: 'Projects',
+ items: [{ label: 'Backend', items: [{ title: 'API', startDate: '2024-01-01', endDate: '2024-01-31' }] }],
+} as const;
+
+describe('timeScale is RETIRED — the old spelling is refused, not silently defaulted (objectui#6355)', () => {
+ it('REFUSES a timeScale-only document, naming the retired key', () => {
+ // The pin. Before the retirement this document parsed GREEN (`timeScale`
+ // was `TimelineScaleSchema.optional()`), which is why this assertion is red
+ // on the pre-fix tree. Asserting the ENVELOPE — not merely `success:false` —
+ // so the pin cannot be satisfied by an unrelated rejection: a document that
+ // failed for its `items` shape would read identically otherwise.
+ const result = TimelineSchema.safeParse(TIMESCALE_ONLY_DOCUMENT);
+ expect(result.success, 'a timeScale-only document was ACCEPTED — it will silently revert to the month default').toBe(false);
+ if (result.success) return;
+
+ const issue = result.error.issues.find((i) => i.path[0] === 'timeScale');
+ expect(issue, 'parse failed, but not on the `timeScale` path').toBeTruthy();
+ expect(issue?.code).toBe('invalid_type');
+ expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
+ });
+
+ it('ACCEPTS the same document migrated to the canonical `scale`', () => {
+ // Counter-probe. Without it the assertion above is satisfied by any schema
+ // that refuses everything, and the pin would prove nothing about the KEY.
+ const { timeScale: _retired, ...rest } = TIMESCALE_ONLY_DOCUMENT;
+ const result = TimelineSchema.safeParse({ ...rest, scale: 'day' });
+ expect(result.success ? null : result.error.issues).toBe(null);
+ });
+
+ it('leaves a document that never wrote the alias untouched', () => {
+ // `absent` stays valid — `.optional()` on the tombstone. The retirement
+ // narrows exactly one spelling and nothing else.
+ expect(TimelineSchema.safeParse({ type: 'timeline' }).success).toBe(true);
+ expect(TimelineSchema.safeParse({ type: 'timeline', variant: 'gantt', scale: 'quarter' }).success).toBe(true);
+ });
+
+ it('keeps `timeScale` DECLARED — a tombstone, not a deletion', () => {
+ // The route guard, and the reason this is a tombstone at all. `BaseSchema`
+ // is `.passthrough()`, so removing the key from the mirror would make the
+ // old spelling parse green again and do nothing — the silent reversion,
+ // reintroduced by the very edit meant to remove it.
+ expect(
+ Object.keys(TimelineSchema.shape),
+ 'timeScale left the mirror — under .passthrough() the retired spelling becomes a SILENT no-op',
+ ).toContain('timeScale');
+ });
+});
+
+describe('timeScale is RETIRED — the TS half of the tombstone (objectui#6355)', () => {
+ it('refuses the retired spelling at compile time', () => {
+ // The mirror twin's compile-time half. On the pre-fix tree `timeScale` is
+ // `TimelineScale | undefined`, so `'month'` is a LEGAL assignment, the
+ // directive below is unused, and `tsc` fails the build with TS2578 naming
+ // the key — this leg is red before the fix in `type-check`, not in vitest,
+ // which strips types. `tsconfig.test.json` compiles this file, so the
+ // directive is real enforcement (objectui#3009).
+
+ // @ts-expect-error — `timeScale` is RETIRED (objectui#6355): declared `?: never`, so no value is authorable.
+ const retired: TimelineSchemaTS['timeScale'] = 'month';
+
+ // Counter-probe on the same surface: the canonical key still accepts the
+ // whole vocabulary, so the directive above pins the KEY's retirement and
+ // not a blanket narrowing of the node.
+ const canonical: TimelineSchemaTS['scale'] = 'month';
+
+ expect([retired, canonical]).toHaveLength(2);
+ });
+
+ it('refuses the retired spelling in the form authors actually write', () => {
+ // The assertion above reads the MEMBER's type. This one writes a DOCUMENT,
+ // which is the shape an author (or an AI generating metadata) produces, and
+ // it is the leg that proves the tombstone survives `BaseSchema`'s
+ // `[key: string]: any`: if the index signature won, `timeScale` would widen
+ // back to `any` here and the directive would go unused (TS2578).
+
+ const retiredDocument: TimelineSchemaTS = {
+ type: 'timeline',
+ variant: 'gantt',
+ // The directive belongs on the PROPERTY, not on the `const`: excess-property
+ // checking reports the error at the member, so a directive one line up
+ // suppresses nothing and goes unused (TS2578) while the real error stands.
+ // @ts-expect-error — `timeScale` is RETIRED (objectui#6355); the document must name `scale`.
+ timeScale: 'month',
+ };
+
+ // The migrated document — same node, canonical key — still type-checks.
+ const migratedDocument: TimelineSchemaTS = {
+ type: 'timeline',
+ variant: 'gantt',
+ scale: 'month',
+ };
+
+ expect([retiredDocument, migratedDocument]).toHaveLength(2);
+ });
+});
diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts
index c6d735d4dd..5365e337d9 100644
--- a/packages/types/src/data-display.ts
+++ b/packages/types/src/data-display.ts
@@ -1395,7 +1395,10 @@ export type TimelineScale = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'yea
* purpose — it is a runtime slot `ObjectTimeline` installs when it composes
* this schema, not authorable metadata, and this package's convention keeps
* callback-shaped keys off the authored surface (see `RuntimeOnlyDeclared` in
- * `__tests__/zod-mirror-parity.test.ts`). The registration's own `inputs` metadata and
+ * `__tests__/zod-mirror-parity.test.ts`). That measurement is a dated record
+ * and is kept as one: `timeScale` has since been RETIRED (objectui#6355,
+ * ruling 2026-08-27), so the read set is EIGHT keys today and `scale` is the
+ * sole axis spelling. The registration's own `inputs` metadata and
* `content/docs/plugins/plugin-timeline.mdx`'s property table agreed with the
* renderer all along; only this type disagreed — including with the docs
* page's own TypeScript example, which did not compile, because `events` was
@@ -1442,21 +1445,36 @@ export interface TimelineSchema extends BaseSchema {
dateFormat?: 'short' | 'long' | 'iso';
/**
* Gantt axis bucket size. **Canonical spelling** — it is `@objectstack/spec`
- * `ui/TimelineConfig.json`'s axis key AND the renderer's preferred read
- * (`resolveTimelineScale` resolves `scale ?? timeScale`).
+ * `ui/TimelineConfig.json`'s axis key AND the renderer's only read
+ * (`resolveTimelineScale`). The `timeScale` alias it used to fall back to is
+ * RETIRED (objectui#6355) and tombstoned below.
* @default 'month'
*/
scale?: TimelineScale;
/**
- * Gantt axis bucket size — this renderer's pre-spec dialect, still read as a
- * fallback so stored JSON keeps working.
+ * RETIRED (objectui#6355, maintainer ruling 2026-08-27) — this renderer's
+ * pre-spec dialect for the gantt axis bucket. Author {@link
+ * TimelineSchema.scale}, which `@objectstack/spec` owns and this renderer
+ * now reads exclusively.
*
- * @deprecated Use {@link TimelineSchema.scale}, which `@objectstack/spec`
- * owns and this renderer prefers. Retiring the alias is routed separately
- * (objectui#6170 maintainer ruling 2026-08-25: "`timeScale` goes the
- * alias-retirement route, not a silent second spelling").
+ * `?: never` is this package's tombstone convention (see {@link
+ * StaticTableColumn} objectui#5474, `crud.ts` `confirm` objectui#4314), and
+ * it is load-bearing rather than decorative. {@link BaseSchema} carries
+ * `[key: string]: any`, so DELETING this member would let the retired
+ * spelling type-check green and do nothing — the renderer no longer reads it,
+ * and the axis would silently fall back to the `month` default with no
+ * diagnostic. That is the silent axis breakage objectui#2942 closed, running
+ * in the other direction. Keeping the key declared as `never` is what makes
+ * the retirement audible at the authoring boundary.
+ *
+ * Lockstep with the Zod twin (`zod/data-display.zod.ts`, `z.never()`): both
+ * halves or neither, since either half alone leaves the other surface
+ * silently accepting the retired spelling. Absent stays valid on both, so a
+ * document that never wrote the alias is untouched.
+ *
+ * @deprecated RETIRED (objectui#6355) — author `scale` instead.
*/
- timeScale?: TimelineScale;
+ timeScale?: never;
/**
* Header label above the Gantt row-label gutter.
* @default 'Items'
diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts
index ac32525771..cc5fb86d55 100644
--- a/packages/types/src/zod/data-display.zod.ts
+++ b/packages/types/src/zod/data-display.zod.ts
@@ -362,6 +362,10 @@ const TimelineScaleSchema = z.enum(['hour', 'day', 'week', 'month', 'quarter', '
* have zero read points, but removing them is a breaking narrowing routed
* through ADR-0049 rather than done here. `events` follows the declaration from
* required to OPTIONAL — strictly more input parses than before.
+ *
+ * `timeScale` is RETIRED (objectui#6355) and carries the `z.never()` tombstone
+ * below — still mirrored, deliberately, because the parity ratchet compares key
+ * SETS and because a tombstone must be present on both halves to be audible.
*/
export const TimelineSchema = BaseSchema.extend({
type: z.literal('timeline'),
@@ -369,7 +373,15 @@ export const TimelineSchema = BaseSchema.extend({
items: z.array(z.any()).optional().describe('Rows to draw — feed items, or gantt rows when variant is gantt'),
dateFormat: z.enum(['short', 'long', 'iso']).optional().describe('How item dates are rendered'),
scale: TimelineScaleSchema.optional().describe('Gantt axis bucket size (canonical spelling — the spec key)'),
- timeScale: TimelineScaleSchema.optional().describe('DEPRECATED pre-spec alias for scale; still read as a fallback'),
+ // RETIRED (objectui#6355, ruling 2026-08-27): the pre-spec alias for `scale`.
+ // The TS twin (`../data-display.ts`) types it `?: never`; here any authored
+ // value is a loud parse rejection (absent stays valid), mirroring how
+ // `@objectstack/spec` retires keys and how `crud.zod.ts` retires `confirm`.
+ // NOT deletable: `BaseSchema` is `.passthrough()`, so dropping the key would
+ // let the retired spelling parse green while the renderer no longer reads it
+ // — a silent revert to the `month` default, which is the whole failure this
+ // retirement exists to make audible. One axis spelling: `scale` above.
+ timeScale: z.never().optional().describe('RETIRED (objectui#6355) — author scale instead'),
rowLabel: z.string().optional().describe('Header label above the gantt row-label gutter'),
minDate: z.string().optional().describe('Override the auto-calculated gantt axis start (YYYY-MM-DD)'),
maxDate: z.string().optional().describe('Override the auto-calculated gantt axis end (YYYY-MM-DD)'),