{f.options.length === 0 ? (
diff --git a/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx b/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx
index 123828e39e..bbc00d1d03 100644
--- a/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx
+++ b/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx
@@ -17,7 +17,7 @@
* the authored contract.
*
* Contract under test:
- * - parity: `FILTER_CONTROL_ARITY` maps exactly the spec's vocabulary;
+ * - parity: `FILTER_CONTROL_KINDS` maps exactly the spec's vocabulary;
* - authored `type: 'select'` renders radios and replaces the pick;
* - authored `type: 'multi-select'` (and an omitted, inferred type) keeps
* accumulating checkboxes;
@@ -27,7 +27,7 @@ import * as React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { UserFilterFieldSchema } from '@objectstack/spec/ui';
-import { UserFilters, FILTER_CONTROL_ARITY } from '../UserFilters';
+import { UserFilters, FILTER_CONTROL_KINDS } from '../UserFilters';
const objectDef = {
name: 'tasks',
@@ -52,7 +52,7 @@ function specControlTypes(): string[] {
return Array.isArray(options) ? [...options] : [];
}
-describe('FILTER_CONTROL_ARITY covers the spec user-filter control vocabulary', () => {
+describe('FILTER_CONTROL_KINDS covers the spec user-filter control vocabulary', () => {
const specNames = specControlTypes();
it('reads a non-empty enum from the spec', () => {
@@ -60,15 +60,15 @@ describe('FILTER_CONTROL_ARITY covers the spec user-filter control vocabulary',
});
it('declares an arity for every control type the spec accepts', () => {
- const undeclared = specNames.filter((name) => !(name in FILTER_CONTROL_ARITY));
+ const undeclared = specNames.filter((name) => !(name in FILTER_CONTROL_KINDS));
expect(
undeclared,
- 'these pass schema validation with no declared selection arity — add them to FILTER_CONTROL_ARITY',
+ 'these pass schema validation with no declared control kind — add them to FILTER_CONTROL_KINDS',
).toEqual([]);
});
it('does not declare control types the spec rejects', () => {
- const extra = Object.keys(FILTER_CONTROL_ARITY).filter((name) => !specNames.includes(name));
+ const extra = Object.keys(FILTER_CONTROL_KINDS).filter((name) => !specNames.includes(name));
expect(
extra,
'these are renderer-local dialect — promote them into @objectstack/spec instead',
@@ -149,3 +149,70 @@ describe("filter type 'select' is single-choice", () => {
expect(onFilterChange).toHaveBeenLastCalledWith([['status', 'in', ['todo']]]);
});
});
+
+describe("Tier 2 (#2942): 'toggle' element and range/text controls render", () => {
+ it("element: 'toggle' renders the filter bar instead of deleting it", () => {
+ const onFilterChange = vi.fn();
+ render(
+
,
+ );
+
+ // The whole bar used to vanish (`default: return null`).
+ expect(screen.getByTestId('user-filters-toggle')).toBeInTheDocument();
+ const btn = screen.getByTestId('filter-toggle-status');
+ // Default-active toggle emitted its filter on mount; clicking it off clears.
+ expect(onFilterChange).toHaveBeenLastCalledWith([['status', 'in', ['todo']]]);
+ fireEvent.click(btn);
+ expect(onFilterChange).toHaveBeenLastCalledWith([]);
+ });
+
+ it("'date-range' renders a from/to pair and emits >=/<= bounds", () => {
+ const onFilterChange = vi.fn();
+ render(
+
,
+ );
+
+ fireEvent.click(screen.getByTestId('filter-badge-due_date'));
+ expect(screen.getByTestId('filter-range-due_date')).toBeInTheDocument();
+ expect(screen.queryByText('No options')).not.toBeInTheDocument();
+
+ fireEvent.change(screen.getByTestId('filter-range-due_date-from'), { target: { value: '2026-01-01' } });
+ expect(onFilterChange).toHaveBeenLastCalledWith([['due_date', '>=', '2026-01-01']]);
+
+ fireEvent.change(screen.getByTestId('filter-range-due_date-to'), { target: { value: '2026-02-01' } });
+ expect(onFilterChange).toHaveBeenLastCalledWith([
+ ['due_date', '>=', '2026-01-01'],
+ ['due_date', '<=', '2026-02-01'],
+ ]);
+ });
+
+ it("'text' renders a search input and emits a contains query on Enter", () => {
+ const onFilterChange = vi.fn();
+ render(
+
,
+ );
+
+ fireEvent.click(screen.getByTestId('filter-badge-name'));
+ const input = screen.getByTestId('filter-text-name');
+ expect(screen.queryByText('No options')).not.toBeInTheDocument();
+
+ fireEvent.change(input, { target: { value: 'acme' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onFilterChange).toHaveBeenLastCalledWith([['name', 'contains', 'acme']]);
+ });
+});
diff --git a/packages/plugin-report/src/ReportViewer.tsx b/packages/plugin-report/src/ReportViewer.tsx
index 8b4918a020..d731cfacde 100644
--- a/packages/plugin-report/src/ReportViewer.tsx
+++ b/packages/plugin-report/src/ReportViewer.tsx
@@ -50,6 +50,37 @@ function groupData(data: any[], groupBy: ReportGroupBy[]): GroupedData[] | null
return result;
}
+/**
+ * Compute one summary value for a report column. Covers every
+ * `ReportAggregationType` member (`@object-ui/types/reports.ts`) — `distinct`
+ * used to fall into `default: return ''` and render a blank cell while the
+ * type accepted it (#2942). Exported for the coverage guard, whose sample
+ * table is `Record
` so a new member fails
+ * type-check until it computes here.
+ */
+export function computeReportAggregation(
+ data: Record[] | undefined,
+ fieldName: string,
+ aggregation?: string,
+): string | number {
+ if (!data) return 0;
+ const values = data.map((item: Record) => Number(item[fieldName]) || 0);
+ switch (aggregation) {
+ case 'count': return data.length;
+ case 'sum': return values.reduce((sum: number, v: number) => sum + v, 0);
+ case 'avg': return values.length > 0
+ ? (values.reduce((sum: number, v: number) => sum + v, 0) / values.length).toFixed(2)
+ : 0;
+ case 'min': return values.length > 0 ? Math.min(...values) : 0;
+ case 'max': return values.length > 0 ? Math.max(...values) : 0;
+ // Distinct values of the RAW field (numeric coercion would fold distinct
+ // strings together).
+ case 'distinct':
+ return new Set(data.map((item: Record) => item[fieldName])).size;
+ default: return '';
+ }
+}
+
export interface ReportViewerProps {
schema: ReportViewerSchema;
/** Callback to refresh data */
@@ -88,20 +119,8 @@ export const ReportViewer: React.FC = ({ schema, onRefresh })
onRefresh?.();
};
- const computeAggregation = (fieldName: string, aggregation?: string): string | number => {
- if (!data) return 0;
- const values = data.map((item: Record) => Number(item[fieldName]) || 0);
- switch (aggregation) {
- case 'count': return data.length;
- case 'sum': return values.reduce((sum: number, v: number) => sum + v, 0);
- case 'avg': return values.length > 0
- ? (values.reduce((sum: number, v: number) => sum + v, 0) / values.length).toFixed(2)
- : 0;
- case 'min': return values.length > 0 ? Math.min(...values) : 0;
- case 'max': return values.length > 0 ? Math.max(...values) : 0;
- default: return '';
- }
- };
+ const computeAggregation = (fieldName: string, aggregation?: string): string | number =>
+ computeReportAggregation(data, fieldName, aggregation);
// Evaluate conditional formatting rules for a cell
const getCellStyle = (fieldName: string, value: any): React.CSSProperties | undefined => {
diff --git a/packages/plugin-report/src/__tests__/report-aggregation-coverage.test.ts b/packages/plugin-report/src/__tests__/report-aggregation-coverage.test.ts
new file mode 100644
index 0000000000..8d939d677f
--- /dev/null
+++ b/packages/plugin-report/src/__tests__/report-aggregation-coverage.test.ts
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ */
+
+/**
+ * Report column aggregation coverage (#2942).
+ *
+ * `ReportAggregationType` (`@object-ui/types/reports.ts`) is the contract for
+ * a report column's `aggregation`; `distinct` used to fall into
+ * `default: return ''` and render a blank summary cell while the type
+ * accepted it. The sample table below is `Record`,
+ * so a NEW union member fails type-check here until someone teaches
+ * `computeReportAggregation` to compute it — the #2897 guard shape, keyed to
+ * the local vocabulary because this legacy viewer's contract is the types
+ * package, not a spec enum.
+ */
+import { describe, it, expect } from 'vitest';
+import type { ReportAggregationType } from '@object-ui/types';
+import { computeReportAggregation } from '../ReportViewer';
+
+const rows = [
+ { amount: 10, stage: 'won' },
+ { amount: 10, stage: 'lost' },
+ { amount: 30, stage: 'won' },
+];
+
+describe('computeReportAggregation covers ReportAggregationType', () => {
+ const expected: Record = {
+ count: 3,
+ sum: 50,
+ avg: '16.67',
+ min: 10,
+ max: 30,
+ distinct: 2, // distinct raw values of `stage` — was a blank cell before #2942
+ };
+
+ for (const [aggregation, value] of Object.entries(expected)) {
+ it(`computes '${aggregation}' (never a blank cell)`, () => {
+ const field = aggregation === 'distinct' ? 'stage' : 'amount';
+ const result = computeReportAggregation(rows, field, aggregation);
+ expect(result).toBe(value);
+ expect(result, `'${aggregation}' must never render blank`).not.toBe('');
+ });
+ }
+
+ it('an out-of-vocabulary aggregation still yields the empty sentinel', () => {
+ expect(computeReportAggregation(rows, 'amount', 'median')).toBe('');
+ });
+});
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
new file mode 100644
index 0000000000..697f0418ab
--- /dev/null
+++ b/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts
@@ -0,0 +1,69 @@
+/**
+ * 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.
+ */
+
+/**
+ * Timeline scale ↔ spec vocabulary parity + behavior (#2942).
+ *
+ * Two drifts lived here: the renderer read its own `timeScale` key, so the
+ * 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.
+ */
+import { describe, it, expect } from 'vitest';
+import { TimelineConfigSchema } from '@objectstack/spec/ui';
+import { TIMELINE_SCALES, resolveTimelineScale, generateTimeScaleHeaders } from '../renderer';
+
+function specScales(): string[] {
+ const scaleSchema = (TimelineConfigSchema as unknown as { shape?: Record })
+ .shape?.scale as { def?: { innerType?: { options?: readonly string[] } } } | undefined;
+ const options = scaleSchema?.def?.innerType?.options;
+ return Array.isArray(options) ? [...options] : [];
+}
+
+describe('timeline covers the spec scale vocabulary', () => {
+ const specNames = specScales();
+
+ it('reads a non-empty enum from the spec', () => {
+ expect(specNames, 'could not read TimelineConfigSchema.shape.scale options from the spec').not.toEqual([]);
+ });
+
+ it('declares exactly the spec scales', () => {
+ expect([...TIMELINE_SCALES].sort()).toEqual([...specNames].sort());
+ });
+
+ it('every spec scale generates a non-empty gantt header row', () => {
+ for (const scale of specNames) {
+ const headers = generateTimeScaleHeaders(scale, '2026-01-01', '2026-01-02');
+ expect(headers.length, `scale '${scale}' blanked the axis`).toBeGreaterThan(0);
+ }
+ });
+
+ it('quarter and year headers read as buckets, not blanks', () => {
+ expect(generateTimeScaleHeaders('quarter', '2026-01-15', '2026-08-01')).toEqual(['Q1 2026', 'Q2 2026', 'Q3 2026']);
+ expect(generateTimeScaleHeaders('year', '2025-06-01', '2027-01-01')).toEqual(['2025', '2026', '2027']);
+ });
+});
+
+describe('resolveTimelineScale honors the spec key first', () => {
+ 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('the spec key wins when both are present', () => {
+ expect(resolveTimelineScale({ scale: 'year', timeScale: 'day' })).toBe('year');
+ });
+
+ it("absent/unknown values keep the renderer's historical month default", () => {
+ expect(resolveTimelineScale({})).toBe('month');
+ expect(resolveTimelineScale({ scale: 'fortnight' })).toBe('month');
+ });
+});
diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx
index 91b0f66c2c..4b94837743 100644
--- a/packages/plugin-timeline/src/renderer.tsx
+++ b/packages/plugin-timeline/src/renderer.tsx
@@ -31,7 +31,87 @@ import {
import { renderChildren, cn } from '@object-ui/components';
// Constants
-const MILLISECONDS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
+/**
+ * The spec's timeline scale vocabulary (`ui/view.zod.ts`
+ * `TimelineConfigSchema.scale`). Exported for the spec-parity test.
+ */
+export const TIMELINE_SCALES: ReadonlySet = new Set([
+ 'hour', 'day', 'week', 'month', 'quarter', 'year',
+]);
+
+/**
+ * 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.
+ */
+export function resolveTimelineScale(schema: { scale?: unknown; timeScale?: unknown }): string {
+ const raw = schema.scale ?? schema.timeScale;
+ return typeof raw === 'string' && TIMELINE_SCALES.has(raw) ? raw : 'month';
+}
+
+/**
+ * Gantt header labels for one scale across [minDate, maxDate]. Every spec
+ * scale produces a non-empty header row — `hour` / `quarter` / `year` used to
+ * fall through the month/week/day chain and return `[]`, blanking the axis
+ * (#2942). Exported for the spec-parity test.
+ */
+export function generateTimeScaleHeaders(scale: string, minDate: string, maxDate: string): string[] {
+ const headers: string[] = [];
+ const start = new Date(minDate);
+ const end = new Date(maxDate);
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start > end) return headers;
+ const current = new Date(start);
+ switch (scale) {
+ case 'hour':
+ while (current <= end) {
+ headers.push(current.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric' }));
+ current.setHours(current.getHours() + 1);
+ }
+ break;
+ case 'day':
+ while (current <= end) {
+ headers.push(current.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
+ current.setDate(current.getDate() + 1);
+ }
+ break;
+ case 'week': {
+ let week = 1;
+ while (current <= end) {
+ headers.push(`Week ${week++}`);
+ current.setDate(current.getDate() + 7);
+ }
+ break;
+ }
+ case 'quarter':
+ current.setMonth(Math.floor(current.getMonth() / 3) * 3, 1);
+ while (current <= end) {
+ headers.push(`Q${Math.floor(current.getMonth() / 3) + 1} ${current.getFullYear()}`);
+ current.setMonth(current.getMonth() + 3);
+ }
+ break;
+ case 'year':
+ // Snap to the calendar-year start so every year touched by the range
+ // gets a bucket (mirrors the quarter snap above).
+ current.setMonth(0, 1);
+ while (current <= end) {
+ headers.push(String(current.getFullYear()));
+ current.setFullYear(current.getFullYear() + 1);
+ }
+ break;
+ case 'month':
+ default:
+ while (current <= end) {
+ headers.push(current.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }));
+ current.setMonth(current.getMonth() + 1);
+ }
+ break;
+ }
+ return headers;
+}
// Helper function to calculate date range from items
function calculateDateRange(items: any[]): { minDate: string; maxDate: string } {
@@ -266,51 +346,14 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
const minDate = schema.minDate || dateRange.minDate;
const maxDate = schema.maxDate || dateRange.maxDate;
- // Generate time scale headers (months, weeks, etc.)
- const timeScale = schema.timeScale || 'month';
- const generateTimeHeaders = () => {
- const headers: string[] = [];
- const start = new Date(minDate);
- const end = new Date(maxDate);
-
- if (timeScale === 'month') {
- const current = new Date(start);
- while (current <= end) {
- headers.push(
- current.toLocaleDateString('en-US', {
- month: 'short',
- year: 'numeric',
- })
- );
- current.setMonth(current.getMonth() + 1);
- }
- } else if (timeScale === 'week') {
- const current = new Date(start);
- while (current <= end) {
- headers.push(
- `Week ${Math.ceil(
- (current.getTime() - start.getTime()) / MILLISECONDS_PER_WEEK
- ) + 1}`
- );
- current.setDate(current.getDate() + 7);
- }
- } else if (timeScale === 'day') {
- const current = new Date(start);
- while (current <= end) {
- headers.push(
- current.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- })
- );
- current.setDate(current.getDate() + 1);
- }
- }
-
- return headers;
- };
-
- const timeHeaders = generateTimeHeaders();
+ // Generate time scale headers — the spec `scale` key drives this
+ // (legacy `timeScale` kept for stored JSON); every spec scale produces
+ // a header row (#2942).
+ const timeHeaders = generateTimeScaleHeaders(
+ resolveTimelineScale(schema as { scale?: unknown; timeScale?: unknown }),
+ minDate,
+ maxDate,
+ );
return (
diff --git a/packages/providers/package.json b/packages/providers/package.json
index f3a9b227f9..cdd96d35b1 100644
--- a/packages/providers/package.json
+++ b/packages/providers/package.json
@@ -37,6 +37,7 @@
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
+ "@objectstack/spec": "^17.0.0-rc.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"typescript": "^6.0.3"
diff --git a/packages/providers/src/ThemeProvider.tsx b/packages/providers/src/ThemeProvider.tsx
index 1372c65991..47485c4a2f 100644
--- a/packages/providers/src/ThemeProvider.tsx
+++ b/packages/providers/src/ThemeProvider.tsx
@@ -30,7 +30,11 @@ export function ThemeProvider({
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
- if (theme === 'system') {
+ // The spec's OS-following mode is `auto` (ThemeModeSchema, ui/theme.zod.ts);
+ // `system` is this provider's pre-spec spelling, kept for stored values.
+ // Branching on `system` alone sent `auto` into `classList.add('auto')` —
+ // a class no Tailwind variant matches, locking the light theme (#2942).
+ if (theme === 'auto' || theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
diff --git a/packages/providers/src/__tests__/theme-mode-spec-parity.test.tsx b/packages/providers/src/__tests__/theme-mode-spec-parity.test.tsx
new file mode 100644
index 0000000000..e822c99899
--- /dev/null
+++ b/packages/providers/src/__tests__/theme-mode-spec-parity.test.tsx
@@ -0,0 +1,88 @@
+/**
+ * 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.
+ */
+
+/**
+ * Theme mode ↔ spec vocabulary parity + behavior (#2942).
+ *
+ * The spec's OS-following mode is `auto` (`ThemeModeSchema`,
+ * `ui/theme.zod.ts`); this provider branched on its pre-spec `system`
+ * spelling only, so `mode: 'auto'` fell into `classList.add('auto')` — a
+ * class no Tailwind variant matches. The page locked to the light theme,
+ * the OS preference was ignored, and nothing errored.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { ThemeModeSchema } from '@objectstack/spec/ui';
+import { ThemeProvider } from '../ThemeProvider';
+
+function mockMatchMedia(prefersDark: boolean) {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: prefersDark,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ onchange: null,
+ })),
+ });
+}
+
+describe('ThemeProvider covers the spec theme-mode vocabulary', () => {
+ beforeEach(() => {
+ document.documentElement.className = '';
+ window.localStorage?.clear?.();
+ mockMatchMedia(true);
+ });
+
+ it('reads a non-empty enum from the spec', () => {
+ const raw = (ThemeModeSchema as unknown as { options?: readonly string[] }).options;
+ expect(Array.isArray(raw) && raw.length > 0, 'could not read ThemeModeSchema.options').toBe(true);
+ });
+
+ it('every spec mode resolves to a real light/dark class — never a dead literal', () => {
+ const raw = (ThemeModeSchema as unknown as { options?: readonly string[] }).options ?? [];
+ for (const mode of raw) {
+ document.documentElement.className = '';
+ const { unmount } = render(
+
+
+ ,
+ );
+ const classes = [...document.documentElement.classList];
+ expect(
+ classes.some((c) => c === 'light' || c === 'dark'),
+ `mode '${mode}' must resolve to light/dark, got [${classes.join(', ')}]`,
+ ).toBe(true);
+ expect(classes, `mode '${mode}' leaked a dead class`).not.toContain(mode === 'auto' ? 'auto' : '__never__');
+ unmount();
+ }
+ });
+
+ it("'auto' follows the OS preference (dark here)", () => {
+ render(
+
+
+ ,
+ );
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+
+ it("legacy 'system' keeps following the OS preference", () => {
+ render(
+
+
+ ,
+ );
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+});
diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts
index 07c7bb577e..36041f7dd3 100644
--- a/packages/providers/src/types.ts
+++ b/packages/providers/src/types.ts
@@ -10,7 +10,9 @@ export interface MetadataProviderProps {
children: ReactNode;
}
-export type Theme = 'light' | 'dark' | 'system';
+// `auto` is the spec's OS-following mode (ThemeModeSchema); `system` is the
+// pre-spec spelling, kept accepted for stored preferences.
+export type Theme = 'light' | 'dark' | 'auto' | 'system';
export interface ThemeProviderProps {
defaultTheme?: Theme;
diff --git a/packages/react/src/context/NotificationContext.tsx b/packages/react/src/context/NotificationContext.tsx
index 32198a7a71..6f923806ea 100644
--- a/packages/react/src/context/NotificationContext.tsx
+++ b/packages/react/src/context/NotificationContext.tsx
@@ -19,18 +19,59 @@ import React, { createContext, useCallback, useContext, useMemo, useState } from
/** Notification severity levels aligned with NotificationSeveritySchema */
export type NotificationSeverityLevel = 'info' | 'success' | 'warning' | 'error';
-/** Notification display type aligned with NotificationTypeSchema */
-export type NotificationDisplayType = 'toast' | 'banner' | 'snackbar' | 'modal';
+/**
+ * Notification display type — the spec `NotificationTypeSchema`
+ * (`ui/notification.zod.ts`: toast / snackbar / banner / alert / inline).
+ * This union used to claim alignment while carrying a renderer-local `modal`
+ * and missing `alert` / `inline` (#2942); `modal` stays accepted as a
+ * deprecated legacy spelling for stored items.
+ */
+export type NotificationDisplayType =
+ | 'toast'
+ | 'snackbar'
+ | 'banner'
+ | 'alert'
+ | 'inline'
+ /** @deprecated renderer dialect — never in the spec; presented as `alert` */
+ | 'modal';
-/** Notification position aligned with NotificationPositionSchema */
+/**
+ * Notification position — the spec `NotificationPositionSchema`
+ * (underscore spellings). The hyphen forms are this context's historical
+ * dialect, kept accepted for stored items.
+ */
export type NotificationPositionValue =
+ | 'top_left'
+ | 'top_center'
+ | 'top_right'
+ | 'bottom_left'
+ | 'bottom_center'
+ | 'bottom_right'
+ /** @deprecated legacy spelling — use `top_left` */
| 'top-left'
+ /** @deprecated legacy spelling — use `top_center` */
| 'top-center'
+ /** @deprecated legacy spelling — use `top_right` */
| 'top-right'
+ /** @deprecated legacy spelling — use `bottom_left` */
| 'bottom-left'
+ /** @deprecated legacy spelling — use `bottom_center` */
| 'bottom-center'
+ /** @deprecated legacy spelling — use `bottom_right` */
| 'bottom-right';
+/**
+ * The spec vocabularies this context implements — exported for the parity
+ * tests (#2942), which fail the moment `NotificationTypeSchema` /
+ * `NotificationPositionSchema` and these sets drift in either direction.
+ */
+export const SUPPORTED_NOTIFICATION_DISPLAY_TYPES: ReadonlySet = new Set([
+ 'toast', 'snackbar', 'banner', 'alert', 'inline',
+]);
+export const SUPPORTED_NOTIFICATION_POSITIONS: ReadonlySet = new Set([
+ 'top_left', 'top_center', 'top_right', 'bottom_left', 'bottom_center', 'bottom_right',
+]);
+
/** Action button on a notification */
export interface NotificationActionButton {
label: string;
@@ -144,6 +185,11 @@ export const NotificationProvider: React.FC = ({
const id = `notification-${++notificationCounter}`;
const notification: NotificationItem = {
...input,
+ // Materialize the declared presentation (spec default: toast; the
+ // legacy `modal` dialect presents as its nearest spec family, alert)
+ // so the delegate can branch on it — it used to be stored and never
+ // read anywhere (#2942).
+ displayType: input.displayType === 'modal' ? 'alert' : (input.displayType ?? 'toast'),
id,
createdAt: new Date(),
read: false,
diff --git a/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx b/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx
new file mode 100644
index 0000000000..0856a3aac7
--- /dev/null
+++ b/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx
@@ -0,0 +1,134 @@
+/**
+ * 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.
+ */
+
+/**
+ * Animation / notification / navigation ↔ spec vocabulary parity (#2942).
+ *
+ * Four hyphen-vs-underscore (and coverage) drifts lived in this package:
+ *
+ * - `useAnimation`'s preset map was keyed in hyphens while the spec enum uses
+ * underscores, and `rotate`/`flip` were absent — 6 of 9 spec presets looked
+ * up NOTHING and rendered no animation;
+ * - its easing map had the same split, and the `EASING_MAP[easing] || easing`
+ * fallthrough emitted `animationTimingFunction: 'ease_in_out'` — invalid
+ * CSS the browser silently dropped;
+ * - `NotificationContext` carried a `modal` dialect, missed `alert`/`inline`,
+ * and keyed positions in hyphens — all under comments claiming alignment;
+ * - `useNavigationOverlay` read only the deprecated `width`, so an authored
+ * `size` bucket was ignored by every host except app-shell.
+ */
+import { describe, it, expect } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import {
+ TransitionPresetSchema,
+ EasingFunctionSchema,
+ NotificationTypeSchema,
+ NotificationPositionSchema,
+} from '@objectstack/spec/ui';
+import {
+ useAnimation,
+ SUPPORTED_TRANSITION_PRESETS,
+ SUPPORTED_EASING_FUNCTIONS,
+} from '../useAnimation';
+import { resolveOverlayWidth } from '../useNavigationOverlay';
+import {
+ SUPPORTED_NOTIFICATION_DISPLAY_TYPES,
+ SUPPORTED_NOTIFICATION_POSITIONS,
+} from '../../context/NotificationContext';
+
+function options(schema: unknown): string[] {
+ const raw = (schema as { options?: readonly string[] }).options;
+ return Array.isArray(raw) ? [...raw] : [];
+}
+
+function assertParity(specNames: string[], implemented: ReadonlySet, what: string) {
+ expect(specNames, `could not read the ${what} enum from the spec`).not.toEqual([]);
+ expect(
+ specNames.filter((name) => !implemented.has(name)),
+ `spec ${what} values with no implementation`,
+ ).toEqual([]);
+ expect(
+ [...implemented].filter((name) => !specNames.includes(name)),
+ `renderer-local ${what} dialect — promote into @objectstack/spec instead`,
+ ).toEqual([]);
+}
+
+describe('react hooks cover the spec animation/notification vocabularies', () => {
+ it('transition presets match TransitionPresetSchema both ways', () => {
+ assertParity(options(TransitionPresetSchema), SUPPORTED_TRANSITION_PRESETS, 'transition preset');
+ });
+
+ it('easing functions match EasingFunctionSchema both ways', () => {
+ assertParity(options(EasingFunctionSchema), SUPPORTED_EASING_FUNCTIONS, 'easing');
+ });
+
+ it('notification display types match NotificationTypeSchema both ways', () => {
+ assertParity(options(NotificationTypeSchema), SUPPORTED_NOTIFICATION_DISPLAY_TYPES, 'notification display type');
+ });
+
+ it('notification positions match NotificationPositionSchema both ways', () => {
+ assertParity(options(NotificationPositionSchema), SUPPORTED_NOTIFICATION_POSITIONS, 'notification position');
+ });
+});
+
+describe('useAnimation renders every spec preset and easing', () => {
+ it('every spec preset except none resolves to non-empty classes', () => {
+ for (const preset of options(TransitionPresetSchema)) {
+ const { result } = renderHook(() => useAnimation({ preset: preset as never }));
+ if (preset === 'none') {
+ expect(result.current.className).toBe('');
+ } else {
+ expect(result.current.className, `preset '${preset}' must animate`).not.toBe('');
+ }
+ }
+ });
+
+ it('every spec easing resolves to valid CSS (never the raw underscore token)', () => {
+ for (const easing of options(EasingFunctionSchema)) {
+ const { result } = renderHook(() => useAnimation({ preset: 'fade', easing: easing as never }));
+ const value = result.current.style.animationTimingFunction;
+ expect(value, `easing '${easing}' must map to CSS`).toBeTruthy();
+ expect(String(value), `easing '${easing}' leaked an invalid raw token`).not.toContain('_');
+ }
+ });
+
+ it('legacy hyphen spellings keep animating (stored-config compatibility)', () => {
+ const { result } = renderHook(() =>
+ useAnimation({ preset: 'slide-up' as never, easing: 'ease-in-out' as never }),
+ );
+ expect(result.current.className).toContain('slide-in-from-bottom');
+ expect(result.current.style.animationTimingFunction).toBe('ease-in-out');
+ });
+
+ it('an out-of-vocabulary easing is dropped rather than emitted as invalid CSS', () => {
+ const { result } = renderHook(() => useAnimation({ preset: 'fade', easing: 'bouncy_town' as never }));
+ expect(result.current.style.animationTimingFunction).toBeUndefined();
+ });
+
+ it('raw CSS timing functions still pass through', () => {
+ const { result } = renderHook(() =>
+ useAnimation({ preset: 'fade', easing: 'cubic-bezier(0.1, 0.2, 0.3, 0.4)' as never }),
+ );
+ expect(result.current.style.animationTimingFunction).toBe('cubic-bezier(0.1, 0.2, 0.3, 0.4)');
+ });
+});
+
+describe('useNavigationOverlay honors the spec size buckets', () => {
+ it('every explicit bucket resolves to a viewport-clamped width', () => {
+ for (const size of ['sm', 'md', 'lg', 'xl', 'full'] as const) {
+ const width = resolveOverlayWidth({ mode: 'drawer', size });
+ expect(width, `size '${size}' must resolve a width`).toMatch(/^min\(92vw, \d+px\)$/);
+ }
+ });
+
+ it("an explicit width wins over the bucket; 'auto' stays host-derived", () => {
+ expect(resolveOverlayWidth({ mode: 'drawer', size: 'xl', width: 500 })).toBe(500);
+ expect(resolveOverlayWidth({ mode: 'drawer', size: 'auto' })).toBeUndefined();
+ expect(resolveOverlayWidth({ mode: 'drawer' })).toBeUndefined();
+ });
+});
diff --git a/packages/react/src/hooks/useAnimation.ts b/packages/react/src/hooks/useAnimation.ts
index 85ece08911..6c1ccb7feb 100644
--- a/packages/react/src/hooks/useAnimation.ts
+++ b/packages/react/src/hooks/useAnimation.ts
@@ -11,25 +11,55 @@ import { useMemo } from 'react';
/** Animation trigger type */
export type AnimationTriggerType = 'enter' | 'exit' | 'hover' | 'focus' | 'click';
-/** Easing presets aligned with EasingFunctionSchema */
+/**
+ * Easing presets aligned with the spec `EasingFunctionSchema`
+ * (`ui/animation.zod.ts` — underscore spellings). The hyphen spellings are
+ * this hook's historical dialect, still accepted at runtime (normalized
+ * before lookup) so existing configs keep animating.
+ */
export type EasingPreset =
| 'linear'
| 'ease'
+ | 'ease_in'
+ | 'ease_out'
+ | 'ease_in_out'
+ | 'spring'
+ /** @deprecated legacy spelling — use `ease_in` */
| 'ease-in'
+ /** @deprecated legacy spelling — use `ease_out` */
| 'ease-out'
- | 'ease-in-out'
- | 'spring';
+ /** @deprecated legacy spelling — use `ease_in_out` */
+ | 'ease-in-out';
-/** Transition preset aligned with TransitionPresetSchema */
+/**
+ * Transition presets aligned with the spec `TransitionPresetSchema`
+ * (`ui/animation.zod.ts` — underscore spellings, including `rotate` and
+ * `flip`). The hyphen spellings and `scale-fade` are this hook's historical
+ * dialect, still accepted at runtime so existing configs keep animating —
+ * but the spec's own spellings used to look up NOTHING here (#2942): the
+ * map was keyed in hyphens, so `preset: 'slide_up'` validated and rendered
+ * no animation at all.
+ */
export type TransitionPresetType =
| 'fade'
+ | 'slide_up'
+ | 'slide_down'
+ | 'slide_left'
+ | 'slide_right'
+ | 'scale'
+ | 'rotate'
+ | 'flip'
+ | 'none'
+ /** @deprecated legacy spelling — use `slide_up` */
| 'slide-up'
+ /** @deprecated legacy spelling — use `slide_down` */
| 'slide-down'
+ /** @deprecated legacy spelling — use `slide_left` */
| 'slide-left'
+ /** @deprecated legacy spelling — use `slide_right` */
| 'slide-right'
- | 'scale'
- | 'scale-fade'
- | 'none';
+ /** @deprecated legacy dialect (never in the spec) — use `flip` */
+ | 'scale-fade';
export interface AnimationConfig {
/** The animation/transition preset */
@@ -52,23 +82,50 @@ export interface AnimationResult {
style: React.CSSProperties;
}
-const EASING_MAP: Record = {
+/**
+ * The spec vocabularies this hook implements — exported for the parity tests,
+ * which fail the moment `TransitionPresetSchema` / `EasingFunctionSchema` and
+ * these sets drift in either direction (#2942).
+ */
+export const SUPPORTED_TRANSITION_PRESETS: ReadonlySet = new Set([
+ 'fade', 'slide_up', 'slide_down', 'slide_left', 'slide_right', 'scale', 'rotate', 'flip', 'none',
+]);
+export const SUPPORTED_EASING_FUNCTIONS: ReadonlySet = new Set([
+ 'linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring',
+]);
+
+/**
+ * Normalize a legacy hyphen spelling (this hook's pre-#2942 dialect) onto the
+ * spec's underscore vocabulary. `scale-fade` never existed in the spec; its
+ * classes were identical to `flip`'s, so it maps there.
+ */
+function normalizePreset(preset: string): string {
+ if (preset === 'scale-fade') return 'flip';
+ return preset.replace(/-/g, '_');
+}
+
+// Keyed by the SPEC spellings (`ui/animation.zod.ts`). Class values for the
+// shared presets are unchanged; `rotate`/`flip` follow `usePageTransition`'s
+// enter classes (spin-in / fade+zoom), the sibling hook that always carried
+// the full spec vocabulary.
+const EASING_MAP: Record = {
linear: 'linear',
ease: 'ease',
- 'ease-in': 'ease-in',
- 'ease-out': 'ease-out',
- 'ease-in-out': 'ease-in-out',
+ ease_in: 'ease-in',
+ ease_out: 'ease-out',
+ ease_in_out: 'ease-in-out',
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
};
-const PRESET_CLASSES: Record = {
+const PRESET_CLASSES: Record = {
fade: 'animate-in fade-in',
- 'slide-up': 'animate-in slide-in-from-bottom-2',
- 'slide-down': 'animate-in slide-in-from-top-2',
- 'slide-left': 'animate-in slide-in-from-right-2',
- 'slide-right': 'animate-in slide-in-from-left-2',
+ slide_up: 'animate-in slide-in-from-bottom-2',
+ slide_down: 'animate-in slide-in-from-top-2',
+ slide_left: 'animate-in slide-in-from-right-2',
+ slide_right: 'animate-in slide-in-from-left-2',
scale: 'animate-in zoom-in-95',
- 'scale-fade': 'animate-in fade-in zoom-in-95',
+ rotate: 'animate-in spin-in-90',
+ flip: 'animate-in fade-in zoom-in-95',
none: '',
};
@@ -99,7 +156,7 @@ export function useAnimation(config: AnimationConfig = {}): AnimationResult {
return { className: '', style: {} };
}
- const className = PRESET_CLASSES[preset] || '';
+ const className = PRESET_CLASSES[normalizePreset(preset)] || '';
const style: React.CSSProperties = {};
if (duration !== undefined) {
@@ -109,7 +166,12 @@ export function useAnimation(config: AnimationConfig = {}): AnimationResult {
style.animationDelay = `${delay}ms`;
}
if (easing) {
- style.animationTimingFunction = EASING_MAP[easing] || easing;
+ // Spec spellings (and legacy hyphen ones) resolve through the map; an
+ // out-of-vocabulary string only passes through when it is plausible raw
+ // CSS (`cubic-bezier(…)`, `steps(…)`) — `ease_in_out` reaching the DOM
+ // verbatim was an invalid declaration the browser silently dropped.
+ style.animationTimingFunction =
+ EASING_MAP[normalizePreset(easing)] ?? (easing.includes('(') ? easing : undefined);
}
return { className, style };
diff --git a/packages/react/src/hooks/useNavigationOverlay.ts b/packages/react/src/hooks/useNavigationOverlay.ts
index 76c5689aad..2914096512 100644
--- a/packages/react/src/hooks/useNavigationOverlay.ts
+++ b/packages/react/src/hooks/useNavigationOverlay.ts
@@ -29,9 +29,45 @@ export interface NavigationConfig {
view?: string;
preventNavigation?: boolean;
openNewTab?: boolean;
+ /** Spec `NavigationConfig.size` (#2578) — coarse overlay bucket. */
+ size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
+ /** @deprecated [#2578 → `size`] explicit pixel/percent width. */
width?: string | number;
}
+/**
+ * Pixel cap per overlay `size` bucket, clamped to the viewport at render —
+ * mirrors `plugin-view/src/recordSurface.ts` (`OVERLAY_SIZE_PX`), which owns
+ * the `size: 'auto'` field-count derivation this layer cannot perform (no
+ * object schema here). Kept in lockstep by the spec-parity test.
+ *
+ * Before #2942 this hook only read the deprecated `width`, so an authored
+ * `size` bucket was silently ignored by every host except app-shell (which
+ * pre-resolves it before calling in).
+ */
+const OVERLAY_SIZE_WIDTHS: Record<'sm' | 'md' | 'lg' | 'xl' | 'full', string> = {
+ sm: 'min(92vw, 480px)',
+ md: 'min(92vw, 720px)',
+ lg: 'min(92vw, 960px)',
+ xl: 'min(92vw, 1200px)',
+ full: 'min(92vw, 1600px)',
+};
+
+/**
+ * Resolve the overlay width from a NavigationConfig: an explicit `width` wins
+ * (app-shell pre-resolves `size` into it); otherwise a declared bucket maps
+ * through {@link OVERLAY_SIZE_WIDTHS}. `'auto'`/absent stays `undefined` — the
+ * host's default width — because deriving `auto` needs the object's field
+ * count, which only schema-aware hosts have. Exported for the parity test.
+ */
+export function resolveOverlayWidth(navigation: NavigationConfig | undefined): string | number | undefined {
+ if (!navigation) return undefined;
+ if (navigation.width !== undefined) return navigation.width;
+ const size = navigation.size;
+ if (size && size !== 'auto') return OVERLAY_SIZE_WIDTHS[size];
+ return undefined;
+}
+
export type NavigationMode = NavigationConfig['mode'];
export interface UseNavigationOverlayOptions {
@@ -124,7 +160,7 @@ export function useNavigationOverlay(
const [selectedRecord, setSelectedRecord] = useState | null>(null);
const mode: NavigationMode = navigation?.mode ?? 'page';
- const width = navigation?.width;
+ const width = resolveOverlayWidth(navigation);
const view = navigation?.view;
const isOverlay = mode === 'drawer' || mode === 'modal' || mode === 'split' || mode === 'popover';
diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts
index 33adae117d..9d01d8eab7 100644
--- a/packages/types/src/zod/objectql.zod.ts
+++ b/packages/types/src/zod/objectql.zod.ts
@@ -255,6 +255,10 @@ const UserFilterTabSchema = z
* User Filters Configuration Schema (Airtable Interfaces-style)
*/
const UserFiltersSchema = z.object({
+ // AUTHORING contract (ADR-0053): `toggle` is deliberately not authorable —
+ // new configs can only write dropdown/tabs. The RENDERER still honors
+ // stored `toggle` metadata (spec ADR-0047 §3.4a keeps it in ITS enum "so
+ // existing configs keep rendering") — see plugin-list `UserFilters`.
element: z.enum(['dropdown', 'tabs']).describe('UI element type'),
fields: z.array(UserFilterFieldSchema).optional().describe('Field-level filters'),
tabs: z.array(UserFilterTabSchema).optional().describe('Named filter presets'),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index da6f48b3af..2a48e9df10 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1201,6 +1201,9 @@ importers:
specifier: ^3.6.0
version: 3.6.0
devDependencies:
+ '@objectstack/spec':
+ specifier: ^17.0.0-rc.0
+ version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3))
'@types/react':
specifier: 19.2.17
version: 19.2.17
@@ -1300,6 +1303,9 @@ importers:
specifier: workspace:*
version: link:../types
devDependencies:
+ '@objectstack/spec':
+ specifier: ^17.0.0-rc.0
+ version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3))
'@types/react':
specifier: 19.2.17
version: 19.2.17
@@ -1472,6 +1478,9 @@ importers:
specifier: ^3.10.1
version: 3.10.1(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.6)(react@19.2.8)(redux@5.0.1)
devDependencies:
+ '@objectstack/spec':
+ specifier: ^17.0.0-rc.0
+ version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3))
'@types/react':
specifier: 19.2.17
version: 19.2.17
@@ -2500,6 +2509,9 @@ importers:
specifier: 19.2.8
version: 19.2.8(react@19.2.8)
devDependencies:
+ '@objectstack/spec':
+ specifier: ^17.0.0-rc.0
+ version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3))
'@types/react':
specifier: 19.2.17
version: 19.2.17