diff --git a/.changeset/11683-formula-datetime-cell-text.md b/.changeset/11683-formula-datetime-cell-text.md
new file mode 100644
index 0000000000..8b8695261e
--- /dev/null
+++ b/.changeset/11683-formula-datetime-cell-text.md
@@ -0,0 +1,10 @@
+---
+'@object-ui/fields': patch
+---
+
+Two cells now draw the right text (objectui#11683).
+
+- **Formula cell (`FormulaCellRenderer`).** A numeric result is drawn by `NumberCellRenderer`, so it is grouped in the viewer's locale and set in the number cell's tabular figures, no longer printed raw in monospace: a formula with no `returnType` holding `200000` reads `200,000` in en-US and zh-CN. A result counts as numeric when the field declares `returnType: 'number'`, or when it declares no `returnType` and the value is a JS number. A declared `scale` sets the width, as on a number field. A string of digits from a formula with no `returnType` stays text, and any other declared `returnType` is drawn as before. `summary` fields use this renderer, so a numeric roll-up is formatted the same way. No type is inferred from the formula's expression or its inputs: the spec's `returnType` has no currency value, so a formula over currency fields reads as a plain number.
+- **Datetime cell (`DateTimeCellRenderer`).** On the compact face, the default, the date and the time are separated by a space in the text, not by a margin alone. Copied text, screen readers and `textContent` get `10/6/2026 1:42 am` (en-US) and `2026/10/6 上午1:42` (zh-CN) where they got the two halves run together. The text is now exactly what `formatDateTime(value, { style: 'compact' })` returns. The time keeps its muted colour; its margin narrows from `ml-2` to `ml-1` beside the space.
+
+No export, prop or language-pack key is added.
diff --git a/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx b/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx
index be82e1a6ad..244c718893 100644
--- a/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx
+++ b/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx
@@ -118,13 +118,14 @@ describe('objectui#10301 — the `datetime` cell', () => {
// This suite runs in UTC (`vitest.config.mts` pins it).
const root = renderCell('datetime', REAL_DATETIME);
expect(affordanceIn(root)).toBeNull();
- expect(root.textContent).toBe('2/28/202610:00 am');
+ // The halves are joined by a space, the compact face's own joiner (objectui#11683).
+ expect(root.textContent).toBe('2/28/2026 10:00 am');
});
it('control: a real day written with an offset renders its instant, not the affordance', () => {
const root = renderCell('datetime', REAL_DAY_WITH_OFFSET);
expect(affordanceIn(root)).toBeNull();
const parts = formatDateTimeCompactParts(new Date(REAL_DAY_WITH_OFFSET), { locale: 'en' });
- expect(root.textContent).toBe(`${parts!.date}${parts!.time}`);
+ expect(root.textContent).toBe(`${parts!.date} ${parts!.time}`);
});
});
diff --git a/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx
index 4094ff94fe..5dcedce26e 100644
--- a/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx
+++ b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx
@@ -215,9 +215,12 @@ describe('every existing datetime cell renders unchanged', () => {
expect(spans[1].textContent).toBe(former.time);
// The time half stays muted and offset — the two-tone face is the visual
// half of "renders unchanged", and collapsing it to one string would be a
- // visible change even with identical text.
+ // visible change even with identical text. Since objectui#11683 the offset
+ // is a text space plus `ml-1` (it was `ml-2` alone, so the text ran the
+ // halves together), and the cell's text is the joined compact face.
expect(spans[1].className).toMatch(/text-muted-foreground/);
- expect(spans[1].className).toMatch(/ml-2/);
+ expect(spans[1].className).toMatch(/\bml-1\b/);
+ expect(container.textContent).toBe(`${former.date} ${former.time}`);
});
it('an authored empty format is still the compact face, not the verbose one', () => {
@@ -229,7 +232,7 @@ describe('every existing datetime cell renders unchanged', () => {
field={{ type: 'datetime', name: 'created_at', format: '' } as any}
/>,
);
- expect(container.textContent).toBe(`${former.date}${former.time}`);
+ expect(container.textContent).toBe(`${former.date} ${former.time}`);
});
});
@@ -270,7 +273,7 @@ describe('field.format now works for datetime, the way it already did for date',
'en-US',
,
);
- expect(container.textContent).toBe('7/4/20247:00 am');
+ expect(container.textContent).toBe('7/4/2024 7:00 am');
});
});
diff --git a/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx b/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx
index a70711ef3b..b3375362a4 100644
--- a/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx
+++ b/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx
@@ -116,10 +116,14 @@ const dateTimeCell = (value: string, format?: string) => (
/>
);
-/** The compact face as the cell paints it — two spans, so no separating space. */
+/**
+ * The compact face as the cell paints it — two spans with a text space between
+ * them, the compact face's own joiner (objectui#11683; before it the gap was a
+ * margin alone, so the text ran the halves together).
+ */
function compactCellText(value: string, locale: string): string {
const parts = formatDateTimeCompactParts(value, { locale })!;
- return `${parts.date}${parts.time}`;
+ return `${parts.date} ${parts.time}`;
}
afterEach(() => {
@@ -153,7 +157,7 @@ describe('THE REPRO — `format: relative` means the same thing in both cells (#
// face is the one this cell already paints, so a measure tile and a grid
// cell showing the same instant agree.
expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe(compactCellText(IN_WINDOW, 'en-US'));
- expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe('9/11/20269:30 am');
+ expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe('9/11/2026 9:30 am');
expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).not.toBe('Sep 11, 2026, 09:30 AM');
});
diff --git a/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx b/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx
new file mode 100644
index 0000000000..7d2f7d615d
--- /dev/null
+++ b/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx
@@ -0,0 +1,181 @@
+/**
+ * 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#11683 — two cells whose TEXT was wrong while their pixels looked
+ * nearly right.
+ *
+ * The 2026-10-06 showcase dogfood (objectui#11672, items 10 and 16) read:
+ *
+ * - Projects' Budget Remaining, a `formula` with no `returnType`, rendered
+ * `200000`, raw and in monospace, beside a formatted currency column.
+ * - A `datetime` cell rendered `2026/10/6上午1:42`: the gap between the date
+ * and the time was an `ml-2` margin, not text, so a copy, a screen reader
+ * and `textContent` got the two halves run together.
+ *
+ * Every case renders through a real `I18nProvider` and sets the tag on the
+ * TENANT locale channel, the first one `useDisplayLocale()` reads, so the
+ * locale under test is the exact tag named here.
+ *
+ * `textContent` is the measured property throughout: it is the text a copy
+ * takes and a screen reader reads, which is what the card is about.
+ *
+ * en-US and zh-CN group thousands the same way, so a formatter that ignored
+ * the locale would pass both. de-DE rides beside them for that reason only.
+ */
+import React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
+import {
+ DateTimeCellRenderer,
+ NumberCellRenderer,
+ formatDateTime,
+ formatDateTimeCompactParts,
+ getCellRenderer,
+} from '../index';
+
+afterEach(() => cleanup());
+
+const LOCALES = ['en-US', 'zh-CN'] as const;
+
+function renderSession(locale: string, node: React.ReactElement) {
+ return render(
+
+ {node}
+ ,
+ );
+}
+
+function textOf(locale: string, node: React.ReactElement): string {
+ const { container } = renderSession(locale, node);
+ const text = container.textContent ?? '';
+ cleanup();
+ return text;
+}
+
+// ── the datetime cell ──────────────────────────────────────────────────────
+
+/** The card's instant: `2026/10/6上午1:42` in zh-CN (the suite runs in UTC). */
+const INSTANT = '2026-10-06T01:42:00.000Z';
+
+const dateTimeCell = (format?: string) => (
+
+);
+
+describe('the datetime cell carries a real separator in its text (objectui#11683)', () => {
+ const LITERAL: Record<(typeof LOCALES)[number], string> = {
+ 'en-US': '10/6/2026 1:42 am',
+ 'zh-CN': '2026/10/6 上午1:42',
+ };
+
+ it.each(LOCALES)('%s — the copied text is the shared compact face, separator included', (locale) => {
+ const text = textOf(locale, dateTimeCell());
+ // By construction: the string every non-cell caller of the compact face gets.
+ expect(text).toBe(formatDateTime(INSTANT, { style: 'compact', locale }));
+ // And by literal, so a change that moved both sides together cannot pass.
+ expect(text).toBe(LITERAL[locale]);
+ });
+
+ it.each(LOCALES)('%s — the run-together text the card read is gone', (locale) => {
+ const parts = formatDateTimeCompactParts(INSTANT, { locale })!;
+ expect(textOf(locale, dateTimeCell())).not.toBe(`${parts.date}${parts.time}`);
+ });
+
+ it.each(LOCALES)('%s — `format: short` reaches the same face, separator included', (locale) => {
+ expect(textOf(locale, dateTimeCell('short'))).toBe(LITERAL[locale]);
+ });
+
+ it.each(LOCALES)('%s — the time half keeps its muted colour', (locale) => {
+ const { container } = renderSession(locale, dateTimeCell());
+ const halves = container.querySelectorAll('span > span');
+ expect(halves).toHaveLength(2);
+ const parts = formatDateTimeCompactParts(INSTANT, { locale })!;
+ expect(halves[0].textContent).toBe(parts.date);
+ expect(halves[1].textContent).toBe(parts.time);
+ expect(halves[1].className).toMatch(/text-muted-foreground/);
+ });
+});
+
+// ── the formula cell ───────────────────────────────────────────────────────
+
+const Formula = getCellRenderer('formula');
+const Summary = getCellRenderer('summary');
+
+const formulaCell = (value: unknown, extra: Record = {}) => (
+
+);
+
+/** What the `number` cell draws for the same value, in the same session. */
+const numberFace = (locale: string, value: number, extra: Record = {}) =>
+ textOf(locale, );
+
+describe('a formula with no returnType formats a numeric result as a number (objectui#11683)', () => {
+ const LITERAL: Record<(typeof LOCALES)[number], string> = {
+ 'en-US': '200,000',
+ 'zh-CN': '200,000',
+ };
+
+ it.each(LOCALES)('%s — Budget Remaining reads 200,000, the number face', (locale) => {
+ const text = textOf(locale, formulaCell(200000));
+ expect(text).toBe(LITERAL[locale]);
+ expect(text).toBe(numberFace(locale, 200000));
+ expect(text).not.toBe('200000');
+ });
+
+ it.each(LOCALES)('%s — the number is not drawn in monospace', (locale) => {
+ const { container } = renderSession(locale, formulaCell(200000));
+ expect(container.querySelector('.font-mono')).toBeNull();
+ expect(container.querySelector('.tabular-nums')).not.toBeNull();
+ });
+
+ it('the locale reaches the formatter: de-DE groups with a dot', () => {
+ expect(textOf('de-DE', formulaCell(200000))).toBe('200.000');
+ });
+
+ it.each(LOCALES)('%s — a fraction keeps its natural precision, as on a number field', (locale) => {
+ expect(textOf(locale, formulaCell(1234.5))).toBe(numberFace(locale, 1234.5));
+ expect(textOf('en-US', formulaCell(1234.5))).toBe('1,234.5');
+ });
+
+ it.each(LOCALES)('%s — a summary roll-up, registered to the same renderer, is a number too', (locale) => {
+ const text = textOf(locale, );
+ expect(text).toBe(numberFace(locale, 8900));
+ expect(text).toBe('8,900');
+ });
+
+ it.each(LOCALES)('%s — a string of digits stays text: only a JS number is read as a number', (locale) => {
+ const { container } = renderSession(locale, formulaCell('200000'));
+ expect(container.textContent).toBe('200000');
+ expect(container.querySelector('.font-mono')).not.toBeNull();
+ });
+});
+
+describe('a declared returnType is read as declared (objectui#11683)', () => {
+ it.each(LOCALES)("%s — returnType 'number' is the number face", (locale) => {
+ expect(textOf(locale, formulaCell(200000, { returnType: 'number' }))).toBe('200,000');
+ });
+
+ it.each(LOCALES)("%s — returnType 'number' reads a numeric string as a number", (locale) => {
+ expect(textOf(locale, formulaCell('200000', { returnType: 'number' }))).toBe('200,000');
+ });
+
+ it.each(LOCALES)('%s — a declared scale is the width (the spec applies scale to a formula)', (locale) => {
+ expect(textOf(locale, formulaCell(200000, { scale: 2 }))).toBe(numberFace(locale, 200000, { scale: 2 }));
+ expect(textOf(locale, formulaCell(200000, { scale: 2 }))).toBe('200,000.00');
+ });
+
+ it.each(LOCALES)("%s — returnType 'text' over a number stays text: the declaration wins", (locale) => {
+ const { container } = renderSession(locale, formulaCell(200000, { returnType: 'text' }));
+ expect(container.textContent).toBe('200000');
+ expect(container.querySelector('.font-mono')).not.toBeNull();
+ });
+});
diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx
index 727e6ab23e..883f72b7dd 100644
--- a/packages/fields/src/index.tsx
+++ b/packages/fields/src/index.tsx
@@ -393,7 +393,10 @@ export { coerceToSafeValue };
* and all three are legitimate:
*
* - **the floor exactly** — `SelectCellRenderer`, `LookupCellRenderer`,
- * `TextCellRenderer`, `FormulaCellRenderer`, `ColorSwatchCellRenderer`, and
+ * `TextCellRenderer`, `FormulaCellRenderer` (a numeric result past its
+ * guard is then drawn by `NumberCellRenderer`, objectui#11683, so a
+ * `returnType: 'number'` formula holding whitespace answers this helper's
+ * extension below), `ColorSwatchCellRenderer`, and
* since objectui#8678 `MaskedCellRenderer` (on the coerced text, as `text`
* reads it), `VectorCellRenderer` and `GridCellRenderer`;
* - **the floor EXTENDED** — this helper (+ whitespace, on the coerced text);
@@ -1257,13 +1260,22 @@ export function DateTimeCellRenderer({ value, field }: CellRendererProps): React
// identically.
// `null` is unreachable: the invalid/empty values it answers for already
// returned `` above.
+ //
+ // The gap between the halves is TEXT, one space (objectui#11683). It used to
+ // be an `ml-2` margin alone, so the cell's text was the two halves run
+ // together: `2026/10/6上午1:42` in zh-CN, `10/6/20261:42 am` in en-US. That is
+ // what a copy, a screen reader and `textContent` got. The space is the joiner
+ // `formatDateTime`'s compact face puts between the same two halves, so the
+ // cell's text is now that string exactly. The time half keeps its muted
+ // colour and a narrower `ml-1` margin, which with the space keeps the halves
+ // visibly apart.
if (style === 'compact') {
const parts = formatDateTimeCompactParts(date, { locale });
if (parts) {
return (
- {parts.date}
- {parts.time}
+ {parts.date}{' '}
+ {parts.time}
);
}
@@ -2644,13 +2656,40 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
}
/**
- * Formula field cell renderer (read-only)
+ * Formula field cell renderer (read-only). `summary` is registered to it too.
*/
-export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement {
+export function FormulaCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
const safe = coerceToSafeValue(value);
// THE FLOOR by name and nothing more (objectui#8496), on the coerced text —
// same relation as `TextCellRenderer`, which this renderer's output mirrors.
if (isEmptyValue(safe)) return ;
+
+ // A NUMERIC result is drawn by the number cell (objectui#11683). This
+ // renderer printed every result as raw monospace text, so the showcase's
+ // Budget Remaining (a formula declaring no `returnType`) read `200000` beside
+ // a formatted currency column. A number now goes through `NumberCellRenderer`,
+ // whose `formatNumberFieldValue` is the one call every number face makes: the
+ // locale's grouping, and the width `resolveFieldScale` answers for this field.
+ // The spec applies `scale` to a `formula` field, so a declared one is honoured.
+ //
+ // Which results are numbers:
+ // - `returnType: 'number'`: the spec's declared value type, read as declared.
+ // - no `returnType`: the value's own JSON type. Only a JS number counts; a
+ // string of digits from an undeclared formula stays text, because nothing
+ // says it is a quantity (a postcode built by concatenation is not one).
+ // - any other `returnType`: drawn as before. A declaration is honoured,
+ // never overridden by the value.
+ //
+ // ⛔ No type is inferred from the expression or from its inputs. The spec's
+ // own field form says of `returnType` that consumers read it "instead of
+ // re-parsing the expression", and its four values (`number` / `text` /
+ // `boolean` / `date`) carry no currency, so a formula over two currency
+ // fields renders as a number here, not as money.
+ const returnType = field && 'returnType' in field ? field.returnType : undefined;
+ if (returnType === 'number' || (returnType === undefined && typeof value === 'number')) {
+ return ;
+ }
+
return (
{String(safe)}