Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/6755-field-diagnostics-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Field widgets say WHY they refused an edit in the reader's language
(objectui#6755, maintainer ruling 2026-08-29).

Three sentences a person has to read to recover from a refusal were string
literals in the widgets, inside a package whose locale channel 11 of its 55
widgets already use: `ObjectField`'s `Invalid JSON`, and `LocationField`'s
format and range refusals (objectui#6716 / #6714). So a zh / ja / ar user who
mistyped a coordinate or a JSON blob was told why in English, in a form whose
labels, gate hints and validation copy were all translated.

- All three now read from `useFieldTranslation` / `FIELD_DEFAULTS` under
`fields.object.invalidJson`, `fields.location.refusedFormat` and
`fields.location.refusedRange`, with entries in all ten locale packs — bound
from now on by `check:i18n-drift`.
- The `en` values are byte-identical to the literals they replace, so English
and provider-less rendering are unchanged, and the refusal pins of
objectui#6716 / #6715 and `plugin-form`'s two refusal suites are untouched.
- `fields.location.refusedRange` keys the FRAME only: the interpolated
`{{detail}}` is `LocationValueSchema`'s own complaint, because the widget must
not restate the spec's bounds (a hand-copied range is a second contract).
- Not in scope, and recorded rather than folded in: `LocationField`'s third
refusal sentence — the residue arm objectui#6715 added after the ruling was
written — is still a literal. objectui#6888 carries it.
240 changes: 240 additions & 0 deletions packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
/**
* 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#6755 — a widget's OWN refusal sentence must reach the locale packs.
*
* `ObjectField` and `LocationField` each render a diagnostic they author
* themselves — the widget's answer to input it refused — and each was a string
* literal in the source while the same package carried a locale channel that 11
* of its 55 widgets already used. So, in the card's words, *"a zh / ja / ar user
* who mistypes a coordinate or a JSON blob is told why in English, inside a
* product whose labels, gate hints and validation copy are all translated"*. The
* defect is not that the string is English (AGENTS.md #-1 requires exactly that
* in the codebase); it is that a translatable surface was never routed through
* the channel that already existed beside it.
*
* Ruled 2026-08-29 by the maintainer: key them, ten pack entries each, bound
* from then on by `check:i18n-drift`. Scope is those THREE sentences — see
* "What is deliberately NOT here" below.
*
* ## What each group asserts, and why in this shape
*
* - **Non-`en` positive AND English-literal negative, together.** A positive-only
* assertion cannot tell a keyed sentence from one that fell back to English,
* because the fallback IS the English sentence — `createSafeTranslation`
* resolves `defaults[key]` when a pack has no entry, so a missing pack value
* renders exactly what the hard-coded literal used to render. Only the pair
* distinguishes "keyed" from "still hard-coded".
* - **`en` and provider-less are NO-OP pins, not defect reproducers.** The three
* pack values are byte-identical to the literals they replace, so English was
* green before this change too. Only a positive assertion can see that the swap
* left English alone — and provider-less rendering is what the widget tests of
* objectui#6716 / #6715 and `plugin-form`'s two refusal suites all measure.
* - **A POSITIVE CONTROL for the pack read, in this same file.** Every negative
* assertion here ("no English survives") is satisfied by a widget that renders
* NOTHING, and every positive one by a pack that happens to be loaded. So one
* test renders `AddressField`, whose `fields.address.*` keys already resolve
* through this very channel (objectui#4028), and asserts its Chinese labels in
* the same run: if the provider or the packs were not live, that control fails
* too, and a blank or English result here cannot be read as a pass.
* - **The RANGE arm's `{{detail}}` stays the spec's own words.** The widget
* builds that sentence from `LocationValueSchema`'s issues, deliberately
* (objectui#6714/#6716: a hand-copied bound is a second contract). Keying it
* therefore keys the FRAME — the part this widget authors — and the interpolated
* detail remains whatever the spec says. The zh assertion below pins exactly
* that division rather than pretending the whole sentence is translated.
*
* ## What is deliberately NOT here
*
* `LocationField`'s THIRD refusal sentence — the residue arm added by
* objectui#6715 after this card was filed and after the ruling was written — is
* still a hard-coded literal. The ruling locks scope to the three sentences it
* names, so it is reported rather than fixed here, and no assertion in this file
* pins its English text: pinning it would read as endorsement of the state the
* follow-up card exists to remove.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { valueSchemaFor } from '@objectstack/spec/data';

import { ObjectField } from '../widgets/ObjectField';
import { LocationField } from '../widgets/LocationField';
import { AddressField } from '../widgets/AddressField';

const LOCATION_SCHEMA = valueSchemaFor({ type: 'location' } as any)!;

const jsonField = { name: 'payload', label: 'Payload', type: 'object' } as any;
const locationField = { name: 'site', label: 'Site', type: 'location' } as any;
const addressField = { name: 'billing_address', type: 'address' } as any;

/** The English sentences this card keyed — the literals that used to be inline. */
const EN_INVALID_JSON = 'Invalid JSON';
const EN_REFUSED_FORMAT =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
/** The frame of the range arm; `{{detail}}` is the spec's own complaint. */
const EN_RANGE_PREFIX = 'Not saved: ';

/**
* What the SPEC says about a pair. Same oracle as
* `LocationField.refusalDiagnostic.test.tsx`: never the literal bounds, which
* would be a second contract that keeps passing on the day the schema moves.
*/
function specDetail(pair: unknown): string {
const parsed = LOCATION_SCHEMA.safeParse(pair);
if (parsed.success) throw new Error('specDetail called on a pair the spec ACCEPTS');
return parsed.error.issues
.map((i: any) => `${i.path.join('.') || 'value'}: ${i.message}`)
.join('; ');
}

/** Mount inside a provider pinned to one language, the way #4028's suite does. */
function renderIn(language: string, element: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>,
);
}

/** The widget's own diagnostic line, or `null` when it announces nothing. */
function diagnostic(container: HTMLElement): string | null {
const p = container.querySelector('p');
return p ? p.textContent : null;
}

function typeInto(container: HTMLElement, text: string) {
const control = container.querySelector('textarea') ?? container.querySelector('input');
fireEvent.change(control as HTMLElement, { target: { value: text } });
}

beforeEach(() => {
cleanup();
});

/* -------------------------------------------------------------------------- */
/* The control: a key that ALREADY resolves through this channel. */
/* -------------------------------------------------------------------------- */

describe('the locale channel is live in this run (control for objectui#6755)', () => {
it('resolves fields.address.* — a key keyed before this card — under zh', () => {
// If this fails, nothing else in this file means anything: a blank or
// English diagnostic below would be a dead provider, not a missing key.
renderIn('zh', <AddressField value={{}} onChange={vi.fn()} field={addressField} />);
expect(screen.getByLabelText('街道地址')).toBeInTheDocument();
expect(screen.getByLabelText('城市')).toBeInTheDocument();
});
});

/* -------------------------------------------------------------------------- */
/* ObjectField — "Invalid JSON". */
/* -------------------------------------------------------------------------- */

describe('ObjectField announces an unparsable draft in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it.each([
['zh', 'JSON 无效'],
['ja', 'JSON が不正です'],
['ar', 'JSON غير صالح'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(expected);
// The negative half: the fallback renders the English literal, so a
// positive-only assertion could not tell a keyed value from a missing one.
expect(container.textContent).not.toContain(EN_INVALID_JSON);
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the FORMAT refusal. */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a FORMAT refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it.each([
['zh', '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。'],
['ja', '保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。'],
['ar', 'لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(expected);
expect(container.textContent).not.toContain(EN_REFUSED_FORMAT);
// The example coordinates stay ASCII digits in every pack: they are what
// the box asks the person to TYPE, not prose.
expect(diagnostic(container)).toContain('30.2741, 120.1551');
});

it('still refuses the value it announced about', () => {
const onChange = vi.fn();
const { container } = renderIn('zh', <LocationField value={null} onChange={onChange} field={locationField} />);
typeInto(container, 'not a coordinate');
// objectui#6714/#6716's rule, unchanged by keying the sentence.
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('input')).toHaveAttribute('aria-invalid', 'true');
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the RANGE refusal (frame keyed, spec detail interpolated). */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a RANGE refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it.each([
['zh', '未保存:'],
['ja', '保存されていません: '],
['ar', 'لم يتم الحفظ: '],
])('translates the FRAME in %s and interpolates the spec\'s own complaint', (language, framePrefix) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
const detail = specDetail({ lat: 999, lng: 999 });
expect(diagnostic(container)).toBe(framePrefix + detail);
// The frame is this widget's own words and is translated; the detail is the
// spec's and is not. Pinning both halves keeps the division deliberate.
expect(container.textContent).not.toContain(EN_RANGE_PREFIX);
expect(diagnostic(container)).toContain(detail);
});
});
43 changes: 36 additions & 7 deletions packages/fields/src/widgets/LocationField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,13 @@ import { LocationValueSchema } from '@objectstack/spec/data';
import type { LocationValue } from '@objectstack/spec/data';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
// The package's declared shape for a `t` forwarded out of a component into a
// message producer — `file-size-guard.ts` exports it and `FileField` /
// `ImageField` already pass `t as TranslateFn` through it. Imported rather than
// re-declared: a second identical type is a second contract, and the name is
// about the FUNCTION, not about files.
import { type TranslateFn } from './file-size-guard.js';

/**
* The stored shape of a `type: 'location'` value — RE-EXPORTED from
Expand DownExpand Up@@ -257,9 +264,15 @@ function draftDenotes(text: string, value: unknown): boolean {
*
* It names the format AND shows it, because the format is the whole content of
* this refusal: the pair is what the box cannot read.
*
* objectui#6755 — the sentence is a locale KEY as of the 2026-08-29 ruling, not
* a literal. The `en` value in `FIELD_DEFAULTS` is byte-identical to the literal
* it replaces, so English and provider-less rendering are unchanged and
* objectui#6716's pins keep saying exactly what they said.
*/
const REFUSED_FORMAT_MESSAGE =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
function refusedFormatMessage(t: TranslateFn): string {
return t('fields.location.refusedFormat');
}

/**
* What the box says when the pair PARSED but the platform refuses its range.
Expand All@@ -269,21 +282,27 @@ const REFUSED_FORMAT_MESSAGE =
* range is a second contract that drifts silently (AGENTS.md #0.1). The
* sentence is built from the SPEC's own issues, so the day the schema moves,
* this message moves with it.
*
* objectui#6755 keys the FRAME — the part this widget authors — and leaves
* `{{detail}}` as whatever the spec said. That division is deliberate and is
* the honest limit of this card: the interpolated complaint is the schema's own
* text, so translating it belongs to whoever owns those messages, not to a
* widget that must not restate them.
*/
function refusedRangeMessage(candidate: LocationValue): string {
function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string {
const parsed = LocationValueSchema.safeParse(candidate);
if (parsed.success) return '';
const detail = parsed.error.issues
.map(issue => `${issue.path.join('.') || 'value'}: ${issue.message}`)
.join('; ');
return `Not saved: ${detail}`;
return t('fields.location.refusedRange', { detail });
}

/**
* What the box says when a half of the pair is only PARTLY a number
* (objectui#6715).
*
* ⛔ Deliberately NOT {@link REFUSED_FORMAT_MESSAGE}. "Enter a latitude,
* ⛔ Deliberately NOT {@link refusedFormatMessage}. "Enter a latitude,
* longitude pair" is unusable advice to someone who typed `12abc, 34`: they
* DID type a pair, and that sentence gives them nothing to correct. This
* refusal names the half that could not be read and quotes it back, because
Expand All@@ -294,6 +313,12 @@ function refusedRangeMessage(candidate: LocationValue): string {
* ⛔ It does not suggest a notation to convert FROM (no `12°N` advice): the
* ruling declines that parse, so pointing at it would advertise a route this
* widget refuses.
*
* ⚠️ Still a LITERAL, alone among the three arms, and deliberately so:
* objectui#6755's ruling locks its scope to the three sentences that existed
* when it was written, and this arm landed after. objectui#6888 carries the
* gap — including the one question the other two did not have to answer, which
* is how `verb` (English grammar, not data) should be keyed.
*/
function refusedResidueMessage(residue: readonly ResidueHalf[]): string {
const named = residue.map(half => `${half.label} "${half.text}"`).join(' and ');
Expand DownExpand Up@@ -358,6 +383,10 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
* this card did not give it one.
*/
const [refusalError, setRefusalError] = useState<string | null>(null);
// objectui#6755 — the two keyed arms below read their sentences from the
// package's locale channel. Called with the other hooks, ABOVE the readonly
// early return, so hook order is the same on both branches.
const { t } = useFieldTranslation();

/**
* Adopt a value that changed OUTSIDE this box — a record finishing its load,
Expand DownExpand Up@@ -413,7 +442,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
if (parsed.kind === 'unparsable') {
// The text is not a coordinate pair. The prior value stands — and since
// objectui#6716 the box says so instead of swallowing the edit.
setRefusalError(REFUSED_FORMAT_MESSAGE);
setRefusalError(refusedFormatMessage(t as TranslateFn));
return;
}

Expand DownExpand Up@@ -443,7 +472,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
}
// objectui#6716: the refusal STANDS — this card does not reverse #6714. It
// only stops the refusal from being silent.
setRefusalError(refusedRangeMessage(emitted));
setRefusalError(refusedRangeMessage(t as TranslateFn, emitted));
};

return (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Route the three widget-local refusal sentences through the fields locale channel by os-sam · Pull Request #6890 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/6755-field-diagnostics-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Field widgets say WHY they refused an edit in the reader's language
(objectui#6755, maintainer ruling 2026-08-29).

Three sentences a person has to read to recover from a refusal were string
literals in the widgets, inside a package whose locale channel 11 of its 55
widgets already use: `ObjectField`'s `Invalid JSON`, and `LocationField`'s
format and range refusals (objectui#6716 / #6714). So a zh / ja / ar user who
mistyped a coordinate or a JSON blob was told why in English, in a form whose
labels, gate hints and validation copy were all translated.

- All three now read from `useFieldTranslation` / `FIELD_DEFAULTS` under
`fields.object.invalidJson`, `fields.location.refusedFormat` and
`fields.location.refusedRange`, with entries in all ten locale packs — bound
from now on by `check:i18n-drift`.
- The `en` values are byte-identical to the literals they replace, so English
and provider-less rendering are unchanged, and the refusal pins of
objectui#6716 / #6715 and `plugin-form`'s two refusal suites are untouched.
- `fields.location.refusedRange` keys the FRAME only: the interpolated
`{{detail}}` is `LocationValueSchema`'s own complaint, because the widget must
not restate the spec's bounds (a hand-copied range is a second contract).
- Not in scope, and recorded rather than folded in: `LocationField`'s third
refusal sentence — the residue arm objectui#6715 added after the ruling was
written — is still a literal. objectui#6888 carries it.
240 changes: 240 additions & 0 deletions packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
/**
* 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#6755 — a widget's OWN refusal sentence must reach the locale packs.
*
* `ObjectField` and `LocationField` each render a diagnostic they author
* themselves — the widget's answer to input it refused — and each was a string
* literal in the source while the same package carried a locale channel that 11
* of its 55 widgets already used. So, in the card's words, *"a zh / ja / ar user
* who mistypes a coordinate or a JSON blob is told why in English, inside a
* product whose labels, gate hints and validation copy are all translated"*. The
* defect is not that the string is English (AGENTS.md #-1 requires exactly that
* in the codebase); it is that a translatable surface was never routed through
* the channel that already existed beside it.
*
* Ruled 2026-08-29 by the maintainer: key them, ten pack entries each, bound
* from then on by `check:i18n-drift`. Scope is those THREE sentences — see
* "What is deliberately NOT here" below.
*
* ## What each group asserts, and why in this shape
*
* - **Non-`en` positive AND English-literal negative, together.** A positive-only
* assertion cannot tell a keyed sentence from one that fell back to English,
* because the fallback IS the English sentence — `createSafeTranslation`
* resolves `defaults[key]` when a pack has no entry, so a missing pack value
* renders exactly what the hard-coded literal used to render. Only the pair
* distinguishes "keyed" from "still hard-coded".
* - **`en` and provider-less are NO-OP pins, not defect reproducers.** The three
* pack values are byte-identical to the literals they replace, so English was
* green before this change too. Only a positive assertion can see that the swap
* left English alone — and provider-less rendering is what the widget tests of
* objectui#6716 / #6715 and `plugin-form`'s two refusal suites all measure.
* - **A POSITIVE CONTROL for the pack read, in this same file.** Every negative
* assertion here ("no English survives") is satisfied by a widget that renders
* NOTHING, and every positive one by a pack that happens to be loaded. So one
* test renders `AddressField`, whose `fields.address.*` keys already resolve
* through this very channel (objectui#4028), and asserts its Chinese labels in
* the same run: if the provider or the packs were not live, that control fails
* too, and a blank or English result here cannot be read as a pass.
* - **The RANGE arm's `{{detail}}` stays the spec's own words.** The widget
* builds that sentence from `LocationValueSchema`'s issues, deliberately
* (objectui#6714/#6716: a hand-copied bound is a second contract). Keying it
* therefore keys the FRAME — the part this widget authors — and the interpolated
* detail remains whatever the spec says. The zh assertion below pins exactly
* that division rather than pretending the whole sentence is translated.
*
* ## What is deliberately NOT here
*
* `LocationField`'s THIRD refusal sentence — the residue arm added by
* objectui#6715 after this card was filed and after the ruling was written — is
* still a hard-coded literal. The ruling locks scope to the three sentences it
* names, so it is reported rather than fixed here, and no assertion in this file
* pins its English text: pinning it would read as endorsement of the state the
* follow-up card exists to remove.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { valueSchemaFor } from '@objectstack/spec/data';

import { ObjectField } from '../widgets/ObjectField';
import { LocationField } from '../widgets/LocationField';
import { AddressField } from '../widgets/AddressField';

const LOCATION_SCHEMA = valueSchemaFor({ type: 'location' } as any)!;

const jsonField = { name: 'payload', label: 'Payload', type: 'object' } as any;
const locationField = { name: 'site', label: 'Site', type: 'location' } as any;
const addressField = { name: 'billing_address', type: 'address' } as any;

/** The English sentences this card keyed — the literals that used to be inline. */
const EN_INVALID_JSON = 'Invalid JSON';
const EN_REFUSED_FORMAT =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
/** The frame of the range arm; `{{detail}}` is the spec's own complaint. */
const EN_RANGE_PREFIX = 'Not saved: ';

/**
* What the SPEC says about a pair. Same oracle as
* `LocationField.refusalDiagnostic.test.tsx`: never the literal bounds, which
* would be a second contract that keeps passing on the day the schema moves.
*/
function specDetail(pair: unknown): string {
const parsed = LOCATION_SCHEMA.safeParse(pair);
if (parsed.success) throw new Error('specDetail called on a pair the spec ACCEPTS');
return parsed.error.issues
.map((i: any) => `${i.path.join('.') || 'value'}: ${i.message}`)
.join('; ');
}

/** Mount inside a provider pinned to one language, the way #4028's suite does. */
function renderIn(language: string, element: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>,
);
}

/** The widget's own diagnostic line, or `null` when it announces nothing. */
function diagnostic(container: HTMLElement): string | null {
const p = container.querySelector('p');
return p ? p.textContent : null;
}

function typeInto(container: HTMLElement, text: string) {
const control = container.querySelector('textarea') ?? container.querySelector('input');
fireEvent.change(control as HTMLElement, { target: { value: text } });
}

beforeEach(() => {
cleanup();
});

/* -------------------------------------------------------------------------- */
/* The control: a key that ALREADY resolves through this channel. */
/* -------------------------------------------------------------------------- */

describe('the locale channel is live in this run (control for objectui#6755)', () => {
it('resolves fields.address.* — a key keyed before this card — under zh', () => {
// If this fails, nothing else in this file means anything: a blank or
// English diagnostic below would be a dead provider, not a missing key.
renderIn('zh', <AddressField value={{}} onChange={vi.fn()} field={addressField} />);
expect(screen.getByLabelText('街道地址')).toBeInTheDocument();
expect(screen.getByLabelText('城市')).toBeInTheDocument();
});
});

/* -------------------------------------------------------------------------- */
/* ObjectField — "Invalid JSON". */
/* -------------------------------------------------------------------------- */

describe('ObjectField announces an unparsable draft in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it.each([
['zh', 'JSON 无效'],
['ja', 'JSON が不正です'],
['ar', 'JSON غير صالح'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(expected);
// The negative half: the fallback renders the English literal, so a
// positive-only assertion could not tell a keyed value from a missing one.
expect(container.textContent).not.toContain(EN_INVALID_JSON);
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the FORMAT refusal. */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a FORMAT refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it.each([
['zh', '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。'],
['ja', '保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。'],
['ar', 'لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(expected);
expect(container.textContent).not.toContain(EN_REFUSED_FORMAT);
// The example coordinates stay ASCII digits in every pack: they are what
// the box asks the person to TYPE, not prose.
expect(diagnostic(container)).toContain('30.2741, 120.1551');
});

it('still refuses the value it announced about', () => {
const onChange = vi.fn();
const { container } = renderIn('zh', <LocationField value={null} onChange={onChange} field={locationField} />);
typeInto(container, 'not a coordinate');
// objectui#6714/#6716's rule, unchanged by keying the sentence.
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('input')).toHaveAttribute('aria-invalid', 'true');
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the RANGE refusal (frame keyed, spec detail interpolated). */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a RANGE refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it.each([
['zh', '未保存:'],
['ja', '保存されていません: '],
['ar', 'لم يتم الحفظ: '],
])('translates the FRAME in %s and interpolates the spec\'s own complaint', (language, framePrefix) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
const detail = specDetail({ lat: 999, lng: 999 });
expect(diagnostic(container)).toBe(framePrefix + detail);
// The frame is this widget's own words and is translated; the detail is the
// spec's and is not. Pinning both halves keeps the division deliberate.
expect(container.textContent).not.toContain(EN_RANGE_PREFIX);
expect(diagnostic(container)).toContain(detail);
});
});
43 changes: 36 additions & 7 deletions packages/fields/src/widgets/LocationField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,13 @@ import { LocationValueSchema } from '@objectstack/spec/data';
import type { LocationValue } from '@objectstack/spec/data';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
// The package's declared shape for a `t` forwarded out of a component into a
// message producer — `file-size-guard.ts` exports it and `FileField` /
// `ImageField` already pass `t as TranslateFn` through it. Imported rather than
// re-declared: a second identical type is a second contract, and the name is
// about the FUNCTION, not about files.
import { type TranslateFn } from './file-size-guard.js';

/**
* The stored shape of a `type: 'location'` value — RE-EXPORTED from
Expand DownExpand Up@@ -257,9 +264,15 @@ function draftDenotes(text: string, value: unknown): boolean {
*
* It names the format AND shows it, because the format is the whole content of
* this refusal: the pair is what the box cannot read.
*
* objectui#6755 — the sentence is a locale KEY as of the 2026-08-29 ruling, not
* a literal. The `en` value in `FIELD_DEFAULTS` is byte-identical to the literal
* it replaces, so English and provider-less rendering are unchanged and
* objectui#6716's pins keep saying exactly what they said.
*/
const REFUSED_FORMAT_MESSAGE =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
function refusedFormatMessage(t: TranslateFn): string {
return t('fields.location.refusedFormat');
}

/**
* What the box says when the pair PARSED but the platform refuses its range.
Expand All@@ -269,21 +282,27 @@ const REFUSED_FORMAT_MESSAGE =
* range is a second contract that drifts silently (AGENTS.md #0.1). The
* sentence is built from the SPEC's own issues, so the day the schema moves,
* this message moves with it.
*
* objectui#6755 keys the FRAME — the part this widget authors — and leaves
* `{{detail}}` as whatever the spec said. That division is deliberate and is
* the honest limit of this card: the interpolated complaint is the schema's own
* text, so translating it belongs to whoever owns those messages, not to a
* widget that must not restate them.
*/
function refusedRangeMessage(candidate: LocationValue): string {
function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string {
const parsed = LocationValueSchema.safeParse(candidate);
if (parsed.success) return '';
const detail = parsed.error.issues
.map(issue => `${issue.path.join('.') || 'value'}: ${issue.message}`)
.join('; ');
return `Not saved: ${detail}`;
return t('fields.location.refusedRange', { detail });
}

/**
* What the box says when a half of the pair is only PARTLY a number
* (objectui#6715).
*
* ⛔ Deliberately NOT {@link REFUSED_FORMAT_MESSAGE}. "Enter a latitude,
* ⛔ Deliberately NOT {@link refusedFormatMessage}. "Enter a latitude,
* longitude pair" is unusable advice to someone who typed `12abc, 34`: they
* DID type a pair, and that sentence gives them nothing to correct. This
* refusal names the half that could not be read and quotes it back, because
Expand All@@ -294,6 +313,12 @@ function refusedRangeMessage(candidate: LocationValue): string {
* ⛔ It does not suggest a notation to convert FROM (no `12°N` advice): the
* ruling declines that parse, so pointing at it would advertise a route this
* widget refuses.
*
* ⚠️ Still a LITERAL, alone among the three arms, and deliberately so:
* objectui#6755's ruling locks its scope to the three sentences that existed
* when it was written, and this arm landed after. objectui#6888 carries the
* gap — including the one question the other two did not have to answer, which
* is how `verb` (English grammar, not data) should be keyed.
*/
function refusedResidueMessage(residue: readonly ResidueHalf[]): string {
const named = residue.map(half => `${half.label} "${half.text}"`).join(' and ');
Expand DownExpand Up@@ -358,6 +383,10 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
* this card did not give it one.
*/
const [refusalError, setRefusalError] = useState<string | null>(null);
// objectui#6755 — the two keyed arms below read their sentences from the
// package's locale channel. Called with the other hooks, ABOVE the readonly
// early return, so hook order is the same on both branches.
const { t } = useFieldTranslation();

/**
* Adopt a value that changed OUTSIDE this box — a record finishing its load,
Expand DownExpand Up@@ -413,7 +442,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
if (parsed.kind === 'unparsable') {
// The text is not a coordinate pair. The prior value stands — and since
// objectui#6716 the box says so instead of swallowing the edit.
setRefusalError(REFUSED_FORMAT_MESSAGE);
setRefusalError(refusedFormatMessage(t as TranslateFn));
return;
}

Expand DownExpand Up@@ -443,7 +472,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
}
// objectui#6716: the refusal STANDS — this card does not reverse #6714. It
// only stops the refusal from being silent.
setRefusalError(refusedRangeMessage(emitted));
setRefusalError(refusedRangeMessage(t as TranslateFn, emitted));
};

return (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Route the three widget-local refusal sentences through the fields locale channel by os-sam · Pull Request #6890 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/6755-field-diagnostics-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Field widgets say WHY they refused an edit in the reader's language
(objectui#6755, maintainer ruling 2026-08-29).

Three sentences a person has to read to recover from a refusal were string
literals in the widgets, inside a package whose locale channel 11 of its 55
widgets already use: `ObjectField`'s `Invalid JSON`, and `LocationField`'s
format and range refusals (objectui#6716 / #6714). So a zh / ja / ar user who
mistyped a coordinate or a JSON blob was told why in English, in a form whose
labels, gate hints and validation copy were all translated.

- All three now read from `useFieldTranslation` / `FIELD_DEFAULTS` under
`fields.object.invalidJson`, `fields.location.refusedFormat` and
`fields.location.refusedRange`, with entries in all ten locale packs — bound
from now on by `check:i18n-drift`.
- The `en` values are byte-identical to the literals they replace, so English
and provider-less rendering are unchanged, and the refusal pins of
objectui#6716 / #6715 and `plugin-form`'s two refusal suites are untouched.
- `fields.location.refusedRange` keys the FRAME only: the interpolated
`{{detail}}` is `LocationValueSchema`'s own complaint, because the widget must
not restate the spec's bounds (a hand-copied range is a second contract).
- Not in scope, and recorded rather than folded in: `LocationField`'s third
refusal sentence — the residue arm objectui#6715 added after the ruling was
written — is still a literal. objectui#6888 carries it.
240 changes: 240 additions & 0 deletions packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
/**
* 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#6755 — a widget's OWN refusal sentence must reach the locale packs.
*
* `ObjectField` and `LocationField` each render a diagnostic they author
* themselves — the widget's answer to input it refused — and each was a string
* literal in the source while the same package carried a locale channel that 11
* of its 55 widgets already used. So, in the card's words, *"a zh / ja / ar user
* who mistypes a coordinate or a JSON blob is told why in English, inside a
* product whose labels, gate hints and validation copy are all translated"*. The
* defect is not that the string is English (AGENTS.md #-1 requires exactly that
* in the codebase); it is that a translatable surface was never routed through
* the channel that already existed beside it.
*
* Ruled 2026-08-29 by the maintainer: key them, ten pack entries each, bound
* from then on by `check:i18n-drift`. Scope is those THREE sentences — see
* "What is deliberately NOT here" below.
*
* ## What each group asserts, and why in this shape
*
* - **Non-`en` positive AND English-literal negative, together.** A positive-only
* assertion cannot tell a keyed sentence from one that fell back to English,
* because the fallback IS the English sentence — `createSafeTranslation`
* resolves `defaults[key]` when a pack has no entry, so a missing pack value
* renders exactly what the hard-coded literal used to render. Only the pair
* distinguishes "keyed" from "still hard-coded".
* - **`en` and provider-less are NO-OP pins, not defect reproducers.** The three
* pack values are byte-identical to the literals they replace, so English was
* green before this change too. Only a positive assertion can see that the swap
* left English alone — and provider-less rendering is what the widget tests of
* objectui#6716 / #6715 and `plugin-form`'s two refusal suites all measure.
* - **A POSITIVE CONTROL for the pack read, in this same file.** Every negative
* assertion here ("no English survives") is satisfied by a widget that renders
* NOTHING, and every positive one by a pack that happens to be loaded. So one
* test renders `AddressField`, whose `fields.address.*` keys already resolve
* through this very channel (objectui#4028), and asserts its Chinese labels in
* the same run: if the provider or the packs were not live, that control fails
* too, and a blank or English result here cannot be read as a pass.
* - **The RANGE arm's `{{detail}}` stays the spec's own words.** The widget
* builds that sentence from `LocationValueSchema`'s issues, deliberately
* (objectui#6714/#6716: a hand-copied bound is a second contract). Keying it
* therefore keys the FRAME — the part this widget authors — and the interpolated
* detail remains whatever the spec says. The zh assertion below pins exactly
* that division rather than pretending the whole sentence is translated.
*
* ## What is deliberately NOT here
*
* `LocationField`'s THIRD refusal sentence — the residue arm added by
* objectui#6715 after this card was filed and after the ruling was written — is
* still a hard-coded literal. The ruling locks scope to the three sentences it
* names, so it is reported rather than fixed here, and no assertion in this file
* pins its English text: pinning it would read as endorsement of the state the
* follow-up card exists to remove.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { valueSchemaFor } from '@objectstack/spec/data';

import { ObjectField } from '../widgets/ObjectField';
import { LocationField } from '../widgets/LocationField';
import { AddressField } from '../widgets/AddressField';

const LOCATION_SCHEMA = valueSchemaFor({ type: 'location' } as any)!;

const jsonField = { name: 'payload', label: 'Payload', type: 'object' } as any;
const locationField = { name: 'site', label: 'Site', type: 'location' } as any;
const addressField = { name: 'billing_address', type: 'address' } as any;

/** The English sentences this card keyed — the literals that used to be inline. */
const EN_INVALID_JSON = 'Invalid JSON';
const EN_REFUSED_FORMAT =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
/** The frame of the range arm; `{{detail}}` is the spec's own complaint. */
const EN_RANGE_PREFIX = 'Not saved: ';

/**
* What the SPEC says about a pair. Same oracle as
* `LocationField.refusalDiagnostic.test.tsx`: never the literal bounds, which
* would be a second contract that keeps passing on the day the schema moves.
*/
function specDetail(pair: unknown): string {
const parsed = LOCATION_SCHEMA.safeParse(pair);
if (parsed.success) throw new Error('specDetail called on a pair the spec ACCEPTS');
return parsed.error.issues
.map((i: any) => `${i.path.join('.') || 'value'}: ${i.message}`)
.join('; ');
}

/** Mount inside a provider pinned to one language, the way #4028's suite does. */
function renderIn(language: string, element: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>,
);
}

/** The widget's own diagnostic line, or `null` when it announces nothing. */
function diagnostic(container: HTMLElement): string | null {
const p = container.querySelector('p');
return p ? p.textContent : null;
}

function typeInto(container: HTMLElement, text: string) {
const control = container.querySelector('textarea') ?? container.querySelector('input');
fireEvent.change(control as HTMLElement, { target: { value: text } });
}

beforeEach(() => {
cleanup();
});

/* -------------------------------------------------------------------------- */
/* The control: a key that ALREADY resolves through this channel. */
/* -------------------------------------------------------------------------- */

describe('the locale channel is live in this run (control for objectui#6755)', () => {
it('resolves fields.address.* — a key keyed before this card — under zh', () => {
// If this fails, nothing else in this file means anything: a blank or
// English diagnostic below would be a dead provider, not a missing key.
renderIn('zh', <AddressField value={{}} onChange={vi.fn()} field={addressField} />);
expect(screen.getByLabelText('街道地址')).toBeInTheDocument();
expect(screen.getByLabelText('城市')).toBeInTheDocument();
});
});

/* -------------------------------------------------------------------------- */
/* ObjectField — "Invalid JSON". */
/* -------------------------------------------------------------------------- */

describe('ObjectField announces an unparsable draft in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it.each([
['zh', 'JSON 无效'],
['ja', 'JSON が不正です'],
['ar', 'JSON غير صالح'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(expected);
// The negative half: the fallback renders the English literal, so a
// positive-only assertion could not tell a keyed value from a missing one.
expect(container.textContent).not.toContain(EN_INVALID_JSON);
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the FORMAT refusal. */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a FORMAT refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it.each([
['zh', '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。'],
['ja', '保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。'],
['ar', 'لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(expected);
expect(container.textContent).not.toContain(EN_REFUSED_FORMAT);
// The example coordinates stay ASCII digits in every pack: they are what
// the box asks the person to TYPE, not prose.
expect(diagnostic(container)).toContain('30.2741, 120.1551');
});

it('still refuses the value it announced about', () => {
const onChange = vi.fn();
const { container } = renderIn('zh', <LocationField value={null} onChange={onChange} field={locationField} />);
typeInto(container, 'not a coordinate');
// objectui#6714/#6716's rule, unchanged by keying the sentence.
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('input')).toHaveAttribute('aria-invalid', 'true');
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the RANGE refusal (frame keyed, spec detail interpolated). */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a RANGE refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it.each([
['zh', '未保存:'],
['ja', '保存されていません: '],
['ar', 'لم يتم الحفظ: '],
])('translates the FRAME in %s and interpolates the spec\'s own complaint', (language, framePrefix) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
const detail = specDetail({ lat: 999, lng: 999 });
expect(diagnostic(container)).toBe(framePrefix + detail);
// The frame is this widget's own words and is translated; the detail is the
// spec's and is not. Pinning both halves keeps the division deliberate.
expect(container.textContent).not.toContain(EN_RANGE_PREFIX);
expect(diagnostic(container)).toContain(detail);
});
});
43 changes: 36 additions & 7 deletions packages/fields/src/widgets/LocationField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,13 @@ import { LocationValueSchema } from '@objectstack/spec/data';
import type { LocationValue } from '@objectstack/spec/data';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
// The package's declared shape for a `t` forwarded out of a component into a
// message producer — `file-size-guard.ts` exports it and `FileField` /
// `ImageField` already pass `t as TranslateFn` through it. Imported rather than
// re-declared: a second identical type is a second contract, and the name is
// about the FUNCTION, not about files.
import { type TranslateFn } from './file-size-guard.js';

/**
* The stored shape of a `type: 'location'` value — RE-EXPORTED from
Expand DownExpand Up@@ -257,9 +264,15 @@ function draftDenotes(text: string, value: unknown): boolean {
*
* It names the format AND shows it, because the format is the whole content of
* this refusal: the pair is what the box cannot read.
*
* objectui#6755 — the sentence is a locale KEY as of the 2026-08-29 ruling, not
* a literal. The `en` value in `FIELD_DEFAULTS` is byte-identical to the literal
* it replaces, so English and provider-less rendering are unchanged and
* objectui#6716's pins keep saying exactly what they said.
*/
const REFUSED_FORMAT_MESSAGE =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
function refusedFormatMessage(t: TranslateFn): string {
return t('fields.location.refusedFormat');
}

/**
* What the box says when the pair PARSED but the platform refuses its range.
Expand All@@ -269,21 +282,27 @@ const REFUSED_FORMAT_MESSAGE =
* range is a second contract that drifts silently (AGENTS.md #0.1). The
* sentence is built from the SPEC's own issues, so the day the schema moves,
* this message moves with it.
*
* objectui#6755 keys the FRAME — the part this widget authors — and leaves
* `{{detail}}` as whatever the spec said. That division is deliberate and is
* the honest limit of this card: the interpolated complaint is the schema's own
* text, so translating it belongs to whoever owns those messages, not to a
* widget that must not restate them.
*/
function refusedRangeMessage(candidate: LocationValue): string {
function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string {
const parsed = LocationValueSchema.safeParse(candidate);
if (parsed.success) return '';
const detail = parsed.error.issues
.map(issue => `${issue.path.join('.') || 'value'}: ${issue.message}`)
.join('; ');
return `Not saved: ${detail}`;
return t('fields.location.refusedRange', { detail });
}

/**
* What the box says when a half of the pair is only PARTLY a number
* (objectui#6715).
*
* ⛔ Deliberately NOT {@link REFUSED_FORMAT_MESSAGE}. "Enter a latitude,
* ⛔ Deliberately NOT {@link refusedFormatMessage}. "Enter a latitude,
* longitude pair" is unusable advice to someone who typed `12abc, 34`: they
* DID type a pair, and that sentence gives them nothing to correct. This
* refusal names the half that could not be read and quotes it back, because
Expand All@@ -294,6 +313,12 @@ function refusedRangeMessage(candidate: LocationValue): string {
* ⛔ It does not suggest a notation to convert FROM (no `12°N` advice): the
* ruling declines that parse, so pointing at it would advertise a route this
* widget refuses.
*
* ⚠️ Still a LITERAL, alone among the three arms, and deliberately so:
* objectui#6755's ruling locks its scope to the three sentences that existed
* when it was written, and this arm landed after. objectui#6888 carries the
* gap — including the one question the other two did not have to answer, which
* is how `verb` (English grammar, not data) should be keyed.
*/
function refusedResidueMessage(residue: readonly ResidueHalf[]): string {
const named = residue.map(half => `${half.label} "${half.text}"`).join(' and ');
Expand DownExpand Up@@ -358,6 +383,10 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
* this card did not give it one.
*/
const [refusalError, setRefusalError] = useState<string | null>(null);
// objectui#6755 — the two keyed arms below read their sentences from the
// package's locale channel. Called with the other hooks, ABOVE the readonly
// early return, so hook order is the same on both branches.
const { t } = useFieldTranslation();

/**
* Adopt a value that changed OUTSIDE this box — a record finishing its load,
Expand DownExpand Up@@ -413,7 +442,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
if (parsed.kind === 'unparsable') {
// The text is not a coordinate pair. The prior value stands — and since
// objectui#6716 the box says so instead of swallowing the edit.
setRefusalError(REFUSED_FORMAT_MESSAGE);
setRefusalError(refusedFormatMessage(t as TranslateFn));
return;
}

Expand DownExpand Up@@ -443,7 +472,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
}
// objectui#6716: the refusal STANDS — this card does not reverse #6714. It
// only stops the refusal from being silent.
setRefusalError(refusedRangeMessage(emitted));
setRefusalError(refusedRangeMessage(t as TranslateFn, emitted));
};

return (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Route the three widget-local refusal sentences through the fields locale channel by os-sam · Pull Request #6890 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/6755-field-diagnostics-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Field widgets say WHY they refused an edit in the reader's language
(objectui#6755, maintainer ruling 2026-08-29).

Three sentences a person has to read to recover from a refusal were string
literals in the widgets, inside a package whose locale channel 11 of its 55
widgets already use: `ObjectField`'s `Invalid JSON`, and `LocationField`'s
format and range refusals (objectui#6716 / #6714). So a zh / ja / ar user who
mistyped a coordinate or a JSON blob was told why in English, in a form whose
labels, gate hints and validation copy were all translated.

- All three now read from `useFieldTranslation` / `FIELD_DEFAULTS` under
`fields.object.invalidJson`, `fields.location.refusedFormat` and
`fields.location.refusedRange`, with entries in all ten locale packs — bound
from now on by `check:i18n-drift`.
- The `en` values are byte-identical to the literals they replace, so English
and provider-less rendering are unchanged, and the refusal pins of
objectui#6716 / #6715 and `plugin-form`'s two refusal suites are untouched.
- `fields.location.refusedRange` keys the FRAME only: the interpolated
`{{detail}}` is `LocationValueSchema`'s own complaint, because the widget must
not restate the spec's bounds (a hand-copied range is a second contract).
- Not in scope, and recorded rather than folded in: `LocationField`'s third
refusal sentence — the residue arm objectui#6715 added after the ruling was
written — is still a literal. objectui#6888 carries it.
240 changes: 240 additions & 0 deletions packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
/**
* 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#6755 — a widget's OWN refusal sentence must reach the locale packs.
*
* `ObjectField` and `LocationField` each render a diagnostic they author
* themselves — the widget's answer to input it refused — and each was a string
* literal in the source while the same package carried a locale channel that 11
* of its 55 widgets already used. So, in the card's words, *"a zh / ja / ar user
* who mistypes a coordinate or a JSON blob is told why in English, inside a
* product whose labels, gate hints and validation copy are all translated"*. The
* defect is not that the string is English (AGENTS.md #-1 requires exactly that
* in the codebase); it is that a translatable surface was never routed through
* the channel that already existed beside it.
*
* Ruled 2026-08-29 by the maintainer: key them, ten pack entries each, bound
* from then on by `check:i18n-drift`. Scope is those THREE sentences — see
* "What is deliberately NOT here" below.
*
* ## What each group asserts, and why in this shape
*
* - **Non-`en` positive AND English-literal negative, together.** A positive-only
* assertion cannot tell a keyed sentence from one that fell back to English,
* because the fallback IS the English sentence — `createSafeTranslation`
* resolves `defaults[key]` when a pack has no entry, so a missing pack value
* renders exactly what the hard-coded literal used to render. Only the pair
* distinguishes "keyed" from "still hard-coded".
* - **`en` and provider-less are NO-OP pins, not defect reproducers.** The three
* pack values are byte-identical to the literals they replace, so English was
* green before this change too. Only a positive assertion can see that the swap
* left English alone — and provider-less rendering is what the widget tests of
* objectui#6716 / #6715 and `plugin-form`'s two refusal suites all measure.
* - **A POSITIVE CONTROL for the pack read, in this same file.** Every negative
* assertion here ("no English survives") is satisfied by a widget that renders
* NOTHING, and every positive one by a pack that happens to be loaded. So one
* test renders `AddressField`, whose `fields.address.*` keys already resolve
* through this very channel (objectui#4028), and asserts its Chinese labels in
* the same run: if the provider or the packs were not live, that control fails
* too, and a blank or English result here cannot be read as a pass.
* - **The RANGE arm's `{{detail}}` stays the spec's own words.** The widget
* builds that sentence from `LocationValueSchema`'s issues, deliberately
* (objectui#6714/#6716: a hand-copied bound is a second contract). Keying it
* therefore keys the FRAME — the part this widget authors — and the interpolated
* detail remains whatever the spec says. The zh assertion below pins exactly
* that division rather than pretending the whole sentence is translated.
*
* ## What is deliberately NOT here
*
* `LocationField`'s THIRD refusal sentence — the residue arm added by
* objectui#6715 after this card was filed and after the ruling was written — is
* still a hard-coded literal. The ruling locks scope to the three sentences it
* names, so it is reported rather than fixed here, and no assertion in this file
* pins its English text: pinning it would read as endorsement of the state the
* follow-up card exists to remove.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { valueSchemaFor } from '@objectstack/spec/data';

import { ObjectField } from '../widgets/ObjectField';
import { LocationField } from '../widgets/LocationField';
import { AddressField } from '../widgets/AddressField';

const LOCATION_SCHEMA = valueSchemaFor({ type: 'location' } as any)!;

const jsonField = { name: 'payload', label: 'Payload', type: 'object' } as any;
const locationField = { name: 'site', label: 'Site', type: 'location' } as any;
const addressField = { name: 'billing_address', type: 'address' } as any;

/** The English sentences this card keyed — the literals that used to be inline. */
const EN_INVALID_JSON = 'Invalid JSON';
const EN_REFUSED_FORMAT =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
/** The frame of the range arm; `{{detail}}` is the spec's own complaint. */
const EN_RANGE_PREFIX = 'Not saved: ';

/**
* What the SPEC says about a pair. Same oracle as
* `LocationField.refusalDiagnostic.test.tsx`: never the literal bounds, which
* would be a second contract that keeps passing on the day the schema moves.
*/
function specDetail(pair: unknown): string {
const parsed = LOCATION_SCHEMA.safeParse(pair);
if (parsed.success) throw new Error('specDetail called on a pair the spec ACCEPTS');
return parsed.error.issues
.map((i: any) => `${i.path.join('.') || 'value'}: ${i.message}`)
.join('; ');
}

/** Mount inside a provider pinned to one language, the way #4028's suite does. */
function renderIn(language: string, element: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>,
);
}

/** The widget's own diagnostic line, or `null` when it announces nothing. */
function diagnostic(container: HTMLElement): string | null {
const p = container.querySelector('p');
return p ? p.textContent : null;
}

function typeInto(container: HTMLElement, text: string) {
const control = container.querySelector('textarea') ?? container.querySelector('input');
fireEvent.change(control as HTMLElement, { target: { value: text } });
}

beforeEach(() => {
cleanup();
});

/* -------------------------------------------------------------------------- */
/* The control: a key that ALREADY resolves through this channel. */
/* -------------------------------------------------------------------------- */

describe('the locale channel is live in this run (control for objectui#6755)', () => {
it('resolves fields.address.* — a key keyed before this card — under zh', () => {
// If this fails, nothing else in this file means anything: a blank or
// English diagnostic below would be a dead provider, not a missing key.
renderIn('zh', <AddressField value={{}} onChange={vi.fn()} field={addressField} />);
expect(screen.getByLabelText('街道地址')).toBeInTheDocument();
expect(screen.getByLabelText('城市')).toBeInTheDocument();
});
});

/* -------------------------------------------------------------------------- */
/* ObjectField — "Invalid JSON". */
/* -------------------------------------------------------------------------- */

describe('ObjectField announces an unparsable draft in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it.each([
['zh', 'JSON 无效'],
['ja', 'JSON が不正です'],
['ar', 'JSON غير صالح'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(expected);
// The negative half: the fallback renders the English literal, so a
// positive-only assertion could not tell a keyed value from a missing one.
expect(container.textContent).not.toContain(EN_INVALID_JSON);
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the FORMAT refusal. */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a FORMAT refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it.each([
['zh', '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。'],
['ja', '保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。'],
['ar', 'لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(expected);
expect(container.textContent).not.toContain(EN_REFUSED_FORMAT);
// The example coordinates stay ASCII digits in every pack: they are what
// the box asks the person to TYPE, not prose.
expect(diagnostic(container)).toContain('30.2741, 120.1551');
});

it('still refuses the value it announced about', () => {
const onChange = vi.fn();
const { container } = renderIn('zh', <LocationField value={null} onChange={onChange} field={locationField} />);
typeInto(container, 'not a coordinate');
// objectui#6714/#6716's rule, unchanged by keying the sentence.
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('input')).toHaveAttribute('aria-invalid', 'true');
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the RANGE refusal (frame keyed, spec detail interpolated). */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a RANGE refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it.each([
['zh', '未保存:'],
['ja', '保存されていません: '],
['ar', 'لم يتم الحفظ: '],
])('translates the FRAME in %s and interpolates the spec\'s own complaint', (language, framePrefix) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
const detail = specDetail({ lat: 999, lng: 999 });
expect(diagnostic(container)).toBe(framePrefix + detail);
// The frame is this widget's own words and is translated; the detail is the
// spec's and is not. Pinning both halves keeps the division deliberate.
expect(container.textContent).not.toContain(EN_RANGE_PREFIX);
expect(diagnostic(container)).toContain(detail);
});
});
43 changes: 36 additions & 7 deletions packages/fields/src/widgets/LocationField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,13 @@ import { LocationValueSchema } from '@objectstack/spec/data';
import type { LocationValue } from '@objectstack/spec/data';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
// The package's declared shape for a `t` forwarded out of a component into a
// message producer — `file-size-guard.ts` exports it and `FileField` /
// `ImageField` already pass `t as TranslateFn` through it. Imported rather than
// re-declared: a second identical type is a second contract, and the name is
// about the FUNCTION, not about files.
import { type TranslateFn } from './file-size-guard.js';

/**
* The stored shape of a `type: 'location'` value — RE-EXPORTED from
Expand DownExpand Up@@ -257,9 +264,15 @@ function draftDenotes(text: string, value: unknown): boolean {
*
* It names the format AND shows it, because the format is the whole content of
* this refusal: the pair is what the box cannot read.
*
* objectui#6755 — the sentence is a locale KEY as of the 2026-08-29 ruling, not
* a literal. The `en` value in `FIELD_DEFAULTS` is byte-identical to the literal
* it replaces, so English and provider-less rendering are unchanged and
* objectui#6716's pins keep saying exactly what they said.
*/
const REFUSED_FORMAT_MESSAGE =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
function refusedFormatMessage(t: TranslateFn): string {
return t('fields.location.refusedFormat');
}

/**
* What the box says when the pair PARSED but the platform refuses its range.
Expand All@@ -269,21 +282,27 @@ const REFUSED_FORMAT_MESSAGE =
* range is a second contract that drifts silently (AGENTS.md #0.1). The
* sentence is built from the SPEC's own issues, so the day the schema moves,
* this message moves with it.
*
* objectui#6755 keys the FRAME — the part this widget authors — and leaves
* `{{detail}}` as whatever the spec said. That division is deliberate and is
* the honest limit of this card: the interpolated complaint is the schema's own
* text, so translating it belongs to whoever owns those messages, not to a
* widget that must not restate them.
*/
function refusedRangeMessage(candidate: LocationValue): string {
function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string {
const parsed = LocationValueSchema.safeParse(candidate);
if (parsed.success) return '';
const detail = parsed.error.issues
.map(issue => `${issue.path.join('.') || 'value'}: ${issue.message}`)
.join('; ');
return `Not saved: ${detail}`;
return t('fields.location.refusedRange', { detail });
}

/**
* What the box says when a half of the pair is only PARTLY a number
* (objectui#6715).
*
* ⛔ Deliberately NOT {@link REFUSED_FORMAT_MESSAGE}. "Enter a latitude,
* ⛔ Deliberately NOT {@link refusedFormatMessage}. "Enter a latitude,
* longitude pair" is unusable advice to someone who typed `12abc, 34`: they
* DID type a pair, and that sentence gives them nothing to correct. This
* refusal names the half that could not be read and quotes it back, because
Expand All@@ -294,6 +313,12 @@ function refusedRangeMessage(candidate: LocationValue): string {
* ⛔ It does not suggest a notation to convert FROM (no `12°N` advice): the
* ruling declines that parse, so pointing at it would advertise a route this
* widget refuses.
*
* ⚠️ Still a LITERAL, alone among the three arms, and deliberately so:
* objectui#6755's ruling locks its scope to the three sentences that existed
* when it was written, and this arm landed after. objectui#6888 carries the
* gap — including the one question the other two did not have to answer, which
* is how `verb` (English grammar, not data) should be keyed.
*/
function refusedResidueMessage(residue: readonly ResidueHalf[]): string {
const named = residue.map(half => `${half.label} "${half.text}"`).join(' and ');
Expand DownExpand Up@@ -358,6 +383,10 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
* this card did not give it one.
*/
const [refusalError, setRefusalError] = useState<string | null>(null);
// objectui#6755 — the two keyed arms below read their sentences from the
// package's locale channel. Called with the other hooks, ABOVE the readonly
// early return, so hook order is the same on both branches.
const { t } = useFieldTranslation();

/**
* Adopt a value that changed OUTSIDE this box — a record finishing its load,
Expand DownExpand Up@@ -413,7 +442,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
if (parsed.kind === 'unparsable') {
// The text is not a coordinate pair. The prior value stands — and since
// objectui#6716 the box says so instead of swallowing the edit.
setRefusalError(REFUSED_FORMAT_MESSAGE);
setRefusalError(refusedFormatMessage(t as TranslateFn));
return;
}

Expand DownExpand Up@@ -443,7 +472,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
}
// objectui#6716: the refusal STANDS — this card does not reverse #6714. It
// only stops the refusal from being silent.
setRefusalError(refusedRangeMessage(emitted));
setRefusalError(refusedRangeMessage(t as TranslateFn, emitted));
};

return (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Route the three widget-local refusal sentences through the fields locale channel by os-sam · Pull Request #6890 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/6755-field-diagnostics-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Field widgets say WHY they refused an edit in the reader's language
(objectui#6755, maintainer ruling 2026-08-29).

Three sentences a person has to read to recover from a refusal were string
literals in the widgets, inside a package whose locale channel 11 of its 55
widgets already use: `ObjectField`'s `Invalid JSON`, and `LocationField`'s
format and range refusals (objectui#6716 / #6714). So a zh / ja / ar user who
mistyped a coordinate or a JSON blob was told why in English, in a form whose
labels, gate hints and validation copy were all translated.

- All three now read from `useFieldTranslation` / `FIELD_DEFAULTS` under
`fields.object.invalidJson`, `fields.location.refusedFormat` and
`fields.location.refusedRange`, with entries in all ten locale packs — bound
from now on by `check:i18n-drift`.
- The `en` values are byte-identical to the literals they replace, so English
and provider-less rendering are unchanged, and the refusal pins of
objectui#6716 / #6715 and `plugin-form`'s two refusal suites are untouched.
- `fields.location.refusedRange` keys the FRAME only: the interpolated
`{{detail}}` is `LocationValueSchema`'s own complaint, because the widget must
not restate the spec's bounds (a hand-copied range is a second contract).
- Not in scope, and recorded rather than folded in: `LocationField`'s third
refusal sentence — the residue arm objectui#6715 added after the ruling was
written — is still a literal. objectui#6888 carries it.
240 changes: 240 additions & 0 deletions packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
/**
* 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#6755 — a widget's OWN refusal sentence must reach the locale packs.
*
* `ObjectField` and `LocationField` each render a diagnostic they author
* themselves — the widget's answer to input it refused — and each was a string
* literal in the source while the same package carried a locale channel that 11
* of its 55 widgets already used. So, in the card's words, *"a zh / ja / ar user
* who mistypes a coordinate or a JSON blob is told why in English, inside a
* product whose labels, gate hints and validation copy are all translated"*. The
* defect is not that the string is English (AGENTS.md #-1 requires exactly that
* in the codebase); it is that a translatable surface was never routed through
* the channel that already existed beside it.
*
* Ruled 2026-08-29 by the maintainer: key them, ten pack entries each, bound
* from then on by `check:i18n-drift`. Scope is those THREE sentences — see
* "What is deliberately NOT here" below.
*
* ## What each group asserts, and why in this shape
*
* - **Non-`en` positive AND English-literal negative, together.** A positive-only
* assertion cannot tell a keyed sentence from one that fell back to English,
* because the fallback IS the English sentence — `createSafeTranslation`
* resolves `defaults[key]` when a pack has no entry, so a missing pack value
* renders exactly what the hard-coded literal used to render. Only the pair
* distinguishes "keyed" from "still hard-coded".
* - **`en` and provider-less are NO-OP pins, not defect reproducers.** The three
* pack values are byte-identical to the literals they replace, so English was
* green before this change too. Only a positive assertion can see that the swap
* left English alone — and provider-less rendering is what the widget tests of
* objectui#6716 / #6715 and `plugin-form`'s two refusal suites all measure.
* - **A POSITIVE CONTROL for the pack read, in this same file.** Every negative
* assertion here ("no English survives") is satisfied by a widget that renders
* NOTHING, and every positive one by a pack that happens to be loaded. So one
* test renders `AddressField`, whose `fields.address.*` keys already resolve
* through this very channel (objectui#4028), and asserts its Chinese labels in
* the same run: if the provider or the packs were not live, that control fails
* too, and a blank or English result here cannot be read as a pass.
* - **The RANGE arm's `{{detail}}` stays the spec's own words.** The widget
* builds that sentence from `LocationValueSchema`'s issues, deliberately
* (objectui#6714/#6716: a hand-copied bound is a second contract). Keying it
* therefore keys the FRAME — the part this widget authors — and the interpolated
* detail remains whatever the spec says. The zh assertion below pins exactly
* that division rather than pretending the whole sentence is translated.
*
* ## What is deliberately NOT here
*
* `LocationField`'s THIRD refusal sentence — the residue arm added by
* objectui#6715 after this card was filed and after the ruling was written — is
* still a hard-coded literal. The ruling locks scope to the three sentences it
* names, so it is reported rather than fixed here, and no assertion in this file
* pins its English text: pinning it would read as endorsement of the state the
* follow-up card exists to remove.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { valueSchemaFor } from '@objectstack/spec/data';

import { ObjectField } from '../widgets/ObjectField';
import { LocationField } from '../widgets/LocationField';
import { AddressField } from '../widgets/AddressField';

const LOCATION_SCHEMA = valueSchemaFor({ type: 'location' } as any)!;

const jsonField = { name: 'payload', label: 'Payload', type: 'object' } as any;
const locationField = { name: 'site', label: 'Site', type: 'location' } as any;
const addressField = { name: 'billing_address', type: 'address' } as any;

/** The English sentences this card keyed — the literals that used to be inline. */
const EN_INVALID_JSON = 'Invalid JSON';
const EN_REFUSED_FORMAT =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
/** The frame of the range arm; `{{detail}}` is the spec's own complaint. */
const EN_RANGE_PREFIX = 'Not saved: ';

/**
* What the SPEC says about a pair. Same oracle as
* `LocationField.refusalDiagnostic.test.tsx`: never the literal bounds, which
* would be a second contract that keeps passing on the day the schema moves.
*/
function specDetail(pair: unknown): string {
const parsed = LOCATION_SCHEMA.safeParse(pair);
if (parsed.success) throw new Error('specDetail called on a pair the spec ACCEPTS');
return parsed.error.issues
.map((i: any) => `${i.path.join('.') || 'value'}: ${i.message}`)
.join('; ');
}

/** Mount inside a provider pinned to one language, the way #4028's suite does. */
function renderIn(language: string, element: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>,
);
}

/** The widget's own diagnostic line, or `null` when it announces nothing. */
function diagnostic(container: HTMLElement): string | null {
const p = container.querySelector('p');
return p ? p.textContent : null;
}

function typeInto(container: HTMLElement, text: string) {
const control = container.querySelector('textarea') ?? container.querySelector('input');
fireEvent.change(control as HTMLElement, { target: { value: text } });
}

beforeEach(() => {
cleanup();
});

/* -------------------------------------------------------------------------- */
/* The control: a key that ALREADY resolves through this channel. */
/* -------------------------------------------------------------------------- */

describe('the locale channel is live in this run (control for objectui#6755)', () => {
it('resolves fields.address.* — a key keyed before this card — under zh', () => {
// If this fails, nothing else in this file means anything: a blank or
// English diagnostic below would be a dead provider, not a missing key.
renderIn('zh', <AddressField value={{}} onChange={vi.fn()} field={addressField} />);
expect(screen.getByLabelText('街道地址')).toBeInTheDocument();
expect(screen.getByLabelText('城市')).toBeInTheDocument();
});
});

/* -------------------------------------------------------------------------- */
/* ObjectField — "Invalid JSON". */
/* -------------------------------------------------------------------------- */

describe('ObjectField announces an unparsable draft in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it.each([
['zh', 'JSON 无效'],
['ja', 'JSON が不正です'],
['ar', 'JSON غير صالح'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(expected);
// The negative half: the fallback renders the English literal, so a
// positive-only assertion could not tell a keyed value from a missing one.
expect(container.textContent).not.toContain(EN_INVALID_JSON);
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the FORMAT refusal. */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a FORMAT refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it.each([
['zh', '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。'],
['ja', '保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。'],
['ar', 'لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(expected);
expect(container.textContent).not.toContain(EN_REFUSED_FORMAT);
// The example coordinates stay ASCII digits in every pack: they are what
// the box asks the person to TYPE, not prose.
expect(diagnostic(container)).toContain('30.2741, 120.1551');
});

it('still refuses the value it announced about', () => {
const onChange = vi.fn();
const { container } = renderIn('zh', <LocationField value={null} onChange={onChange} field={locationField} />);
typeInto(container, 'not a coordinate');
// objectui#6714/#6716's rule, unchanged by keying the sentence.
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('input')).toHaveAttribute('aria-invalid', 'true');
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the RANGE refusal (frame keyed, spec detail interpolated). */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a RANGE refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it.each([
['zh', '未保存:'],
['ja', '保存されていません: '],
['ar', 'لم يتم الحفظ: '],
])('translates the FRAME in %s and interpolates the spec\'s own complaint', (language, framePrefix) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
const detail = specDetail({ lat: 999, lng: 999 });
expect(diagnostic(container)).toBe(framePrefix + detail);
// The frame is this widget's own words and is translated; the detail is the
// spec's and is not. Pinning both halves keeps the division deliberate.
expect(container.textContent).not.toContain(EN_RANGE_PREFIX);
expect(diagnostic(container)).toContain(detail);
});
});
43 changes: 36 additions & 7 deletions packages/fields/src/widgets/LocationField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,13 @@ import { LocationValueSchema } from '@objectstack/spec/data';
import type { LocationValue } from '@objectstack/spec/data';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
// The package's declared shape for a `t` forwarded out of a component into a
// message producer — `file-size-guard.ts` exports it and `FileField` /
// `ImageField` already pass `t as TranslateFn` through it. Imported rather than
// re-declared: a second identical type is a second contract, and the name is
// about the FUNCTION, not about files.
import { type TranslateFn } from './file-size-guard.js';

/**
* The stored shape of a `type: 'location'` value — RE-EXPORTED from
Expand DownExpand Up@@ -257,9 +264,15 @@ function draftDenotes(text: string, value: unknown): boolean {
*
* It names the format AND shows it, because the format is the whole content of
* this refusal: the pair is what the box cannot read.
*
* objectui#6755 — the sentence is a locale KEY as of the 2026-08-29 ruling, not
* a literal. The `en` value in `FIELD_DEFAULTS` is byte-identical to the literal
* it replaces, so English and provider-less rendering are unchanged and
* objectui#6716's pins keep saying exactly what they said.
*/
const REFUSED_FORMAT_MESSAGE =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
function refusedFormatMessage(t: TranslateFn): string {
return t('fields.location.refusedFormat');
}

/**
* What the box says when the pair PARSED but the platform refuses its range.
Expand All@@ -269,21 +282,27 @@ const REFUSED_FORMAT_MESSAGE =
* range is a second contract that drifts silently (AGENTS.md #0.1). The
* sentence is built from the SPEC's own issues, so the day the schema moves,
* this message moves with it.
*
* objectui#6755 keys the FRAME — the part this widget authors — and leaves
* `{{detail}}` as whatever the spec said. That division is deliberate and is
* the honest limit of this card: the interpolated complaint is the schema's own
* text, so translating it belongs to whoever owns those messages, not to a
* widget that must not restate them.
*/
function refusedRangeMessage(candidate: LocationValue): string {
function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string {
const parsed = LocationValueSchema.safeParse(candidate);
if (parsed.success) return '';
const detail = parsed.error.issues
.map(issue => `${issue.path.join('.') || 'value'}: ${issue.message}`)
.join('; ');
return `Not saved: ${detail}`;
return t('fields.location.refusedRange', { detail });
}

/**
* What the box says when a half of the pair is only PARTLY a number
* (objectui#6715).
*
* ⛔ Deliberately NOT {@link REFUSED_FORMAT_MESSAGE}. "Enter a latitude,
* ⛔ Deliberately NOT {@link refusedFormatMessage}. "Enter a latitude,
* longitude pair" is unusable advice to someone who typed `12abc, 34`: they
* DID type a pair, and that sentence gives them nothing to correct. This
* refusal names the half that could not be read and quotes it back, because
Expand All@@ -294,6 +313,12 @@ function refusedRangeMessage(candidate: LocationValue): string {
* ⛔ It does not suggest a notation to convert FROM (no `12°N` advice): the
* ruling declines that parse, so pointing at it would advertise a route this
* widget refuses.
*
* ⚠️ Still a LITERAL, alone among the three arms, and deliberately so:
* objectui#6755's ruling locks its scope to the three sentences that existed
* when it was written, and this arm landed after. objectui#6888 carries the
* gap — including the one question the other two did not have to answer, which
* is how `verb` (English grammar, not data) should be keyed.
*/
function refusedResidueMessage(residue: readonly ResidueHalf[]): string {
const named = residue.map(half => `${half.label} "${half.text}"`).join(' and ');
Expand DownExpand Up@@ -358,6 +383,10 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
* this card did not give it one.
*/
const [refusalError, setRefusalError] = useState<string | null>(null);
// objectui#6755 — the two keyed arms below read their sentences from the
// package's locale channel. Called with the other hooks, ABOVE the readonly
// early return, so hook order is the same on both branches.
const { t } = useFieldTranslation();

/**
* Adopt a value that changed OUTSIDE this box — a record finishing its load,
Expand DownExpand Up@@ -413,7 +442,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
if (parsed.kind === 'unparsable') {
// The text is not a coordinate pair. The prior value stands — and since
// objectui#6716 the box says so instead of swallowing the edit.
setRefusalError(REFUSED_FORMAT_MESSAGE);
setRefusalError(refusedFormatMessage(t as TranslateFn));
return;
}

Expand DownExpand Up@@ -443,7 +472,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
}
// objectui#6716: the refusal STANDS — this card does not reverse #6714. It
// only stops the refusal from being silent.
setRefusalError(refusedRangeMessage(emitted));
setRefusalError(refusedRangeMessage(t as TranslateFn, emitted));
};

return (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Route the three widget-local refusal sentences through the fields locale channel by os-sam · Pull Request #6890 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/6755-field-diagnostics-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Field widgets say WHY they refused an edit in the reader's language
(objectui#6755, maintainer ruling 2026-08-29).

Three sentences a person has to read to recover from a refusal were string
literals in the widgets, inside a package whose locale channel 11 of its 55
widgets already use: `ObjectField`'s `Invalid JSON`, and `LocationField`'s
format and range refusals (objectui#6716 / #6714). So a zh / ja / ar user who
mistyped a coordinate or a JSON blob was told why in English, in a form whose
labels, gate hints and validation copy were all translated.

- All three now read from `useFieldTranslation` / `FIELD_DEFAULTS` under
`fields.object.invalidJson`, `fields.location.refusedFormat` and
`fields.location.refusedRange`, with entries in all ten locale packs — bound
from now on by `check:i18n-drift`.
- The `en` values are byte-identical to the literals they replace, so English
and provider-less rendering are unchanged, and the refusal pins of
objectui#6716 / #6715 and `plugin-form`'s two refusal suites are untouched.
- `fields.location.refusedRange` keys the FRAME only: the interpolated
`{{detail}}` is `LocationValueSchema`'s own complaint, because the widget must
not restate the spec's bounds (a hand-copied range is a second contract).
- Not in scope, and recorded rather than folded in: `LocationField`'s third
refusal sentence — the residue arm objectui#6715 added after the ruling was
written — is still a literal. objectui#6888 carries it.
240 changes: 240 additions & 0 deletions packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
/**
* 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#6755 — a widget's OWN refusal sentence must reach the locale packs.
*
* `ObjectField` and `LocationField` each render a diagnostic they author
* themselves — the widget's answer to input it refused — and each was a string
* literal in the source while the same package carried a locale channel that 11
* of its 55 widgets already used. So, in the card's words, *"a zh / ja / ar user
* who mistypes a coordinate or a JSON blob is told why in English, inside a
* product whose labels, gate hints and validation copy are all translated"*. The
* defect is not that the string is English (AGENTS.md #-1 requires exactly that
* in the codebase); it is that a translatable surface was never routed through
* the channel that already existed beside it.
*
* Ruled 2026-08-29 by the maintainer: key them, ten pack entries each, bound
* from then on by `check:i18n-drift`. Scope is those THREE sentences — see
* "What is deliberately NOT here" below.
*
* ## What each group asserts, and why in this shape
*
* - **Non-`en` positive AND English-literal negative, together.** A positive-only
* assertion cannot tell a keyed sentence from one that fell back to English,
* because the fallback IS the English sentence — `createSafeTranslation`
* resolves `defaults[key]` when a pack has no entry, so a missing pack value
* renders exactly what the hard-coded literal used to render. Only the pair
* distinguishes "keyed" from "still hard-coded".
* - **`en` and provider-less are NO-OP pins, not defect reproducers.** The three
* pack values are byte-identical to the literals they replace, so English was
* green before this change too. Only a positive assertion can see that the swap
* left English alone — and provider-less rendering is what the widget tests of
* objectui#6716 / #6715 and `plugin-form`'s two refusal suites all measure.
* - **A POSITIVE CONTROL for the pack read, in this same file.** Every negative
* assertion here ("no English survives") is satisfied by a widget that renders
* NOTHING, and every positive one by a pack that happens to be loaded. So one
* test renders `AddressField`, whose `fields.address.*` keys already resolve
* through this very channel (objectui#4028), and asserts its Chinese labels in
* the same run: if the provider or the packs were not live, that control fails
* too, and a blank or English result here cannot be read as a pass.
* - **The RANGE arm's `{{detail}}` stays the spec's own words.** The widget
* builds that sentence from `LocationValueSchema`'s issues, deliberately
* (objectui#6714/#6716: a hand-copied bound is a second contract). Keying it
* therefore keys the FRAME — the part this widget authors — and the interpolated
* detail remains whatever the spec says. The zh assertion below pins exactly
* that division rather than pretending the whole sentence is translated.
*
* ## What is deliberately NOT here
*
* `LocationField`'s THIRD refusal sentence — the residue arm added by
* objectui#6715 after this card was filed and after the ruling was written — is
* still a hard-coded literal. The ruling locks scope to the three sentences it
* names, so it is reported rather than fixed here, and no assertion in this file
* pins its English text: pinning it would read as endorsement of the state the
* follow-up card exists to remove.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { valueSchemaFor } from '@objectstack/spec/data';

import { ObjectField } from '../widgets/ObjectField';
import { LocationField } from '../widgets/LocationField';
import { AddressField } from '../widgets/AddressField';

const LOCATION_SCHEMA = valueSchemaFor({ type: 'location' } as any)!;

const jsonField = { name: 'payload', label: 'Payload', type: 'object' } as any;
const locationField = { name: 'site', label: 'Site', type: 'location' } as any;
const addressField = { name: 'billing_address', type: 'address' } as any;

/** The English sentences this card keyed — the literals that used to be inline. */
const EN_INVALID_JSON = 'Invalid JSON';
const EN_REFUSED_FORMAT =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
/** The frame of the range arm; `{{detail}}` is the spec's own complaint. */
const EN_RANGE_PREFIX = 'Not saved: ';

/**
* What the SPEC says about a pair. Same oracle as
* `LocationField.refusalDiagnostic.test.tsx`: never the literal bounds, which
* would be a second contract that keeps passing on the day the schema moves.
*/
function specDetail(pair: unknown): string {
const parsed = LOCATION_SCHEMA.safeParse(pair);
if (parsed.success) throw new Error('specDetail called on a pair the spec ACCEPTS');
return parsed.error.issues
.map((i: any) => `${i.path.join('.') || 'value'}: ${i.message}`)
.join('; ');
}

/** Mount inside a provider pinned to one language, the way #4028's suite does. */
function renderIn(language: string, element: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>,
);
}

/** The widget's own diagnostic line, or `null` when it announces nothing. */
function diagnostic(container: HTMLElement): string | null {
const p = container.querySelector('p');
return p ? p.textContent : null;
}

function typeInto(container: HTMLElement, text: string) {
const control = container.querySelector('textarea') ?? container.querySelector('input');
fireEvent.change(control as HTMLElement, { target: { value: text } });
}

beforeEach(() => {
cleanup();
});

/* -------------------------------------------------------------------------- */
/* The control: a key that ALREADY resolves through this channel. */
/* -------------------------------------------------------------------------- */

describe('the locale channel is live in this run (control for objectui#6755)', () => {
it('resolves fields.address.* — a key keyed before this card — under zh', () => {
// If this fails, nothing else in this file means anything: a blank or
// English diagnostic below would be a dead provider, not a missing key.
renderIn('zh', <AddressField value={{}} onChange={vi.fn()} field={addressField} />);
expect(screen.getByLabelText('街道地址')).toBeInTheDocument();
expect(screen.getByLabelText('城市')).toBeInTheDocument();
});
});

/* -------------------------------------------------------------------------- */
/* ObjectField — "Invalid JSON". */
/* -------------------------------------------------------------------------- */

describe('ObjectField announces an unparsable draft in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it.each([
['zh', 'JSON 无效'],
['ja', 'JSON が不正です'],
['ar', 'JSON غير صالح'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(expected);
// The negative half: the fallback renders the English literal, so a
// positive-only assertion could not tell a keyed value from a missing one.
expect(container.textContent).not.toContain(EN_INVALID_JSON);
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the FORMAT refusal. */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a FORMAT refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it.each([
['zh', '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。'],
['ja', '保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。'],
['ar', 'لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(expected);
expect(container.textContent).not.toContain(EN_REFUSED_FORMAT);
// The example coordinates stay ASCII digits in every pack: they are what
// the box asks the person to TYPE, not prose.
expect(diagnostic(container)).toContain('30.2741, 120.1551');
});

it('still refuses the value it announced about', () => {
const onChange = vi.fn();
const { container } = renderIn('zh', <LocationField value={null} onChange={onChange} field={locationField} />);
typeInto(container, 'not a coordinate');
// objectui#6714/#6716's rule, unchanged by keying the sentence.
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('input')).toHaveAttribute('aria-invalid', 'true');
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the RANGE refusal (frame keyed, spec detail interpolated). */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a RANGE refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it.each([
['zh', '未保存:'],
['ja', '保存されていません: '],
['ar', 'لم يتم الحفظ: '],
])('translates the FRAME in %s and interpolates the spec\'s own complaint', (language, framePrefix) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
const detail = specDetail({ lat: 999, lng: 999 });
expect(diagnostic(container)).toBe(framePrefix + detail);
// The frame is this widget's own words and is translated; the detail is the
// spec's and is not. Pinning both halves keeps the division deliberate.
expect(container.textContent).not.toContain(EN_RANGE_PREFIX);
expect(diagnostic(container)).toContain(detail);
});
});
43 changes: 36 additions & 7 deletions packages/fields/src/widgets/LocationField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,13 @@ import { LocationValueSchema } from '@objectstack/spec/data';
import type { LocationValue } from '@objectstack/spec/data';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
// The package's declared shape for a `t` forwarded out of a component into a
// message producer — `file-size-guard.ts` exports it and `FileField` /
// `ImageField` already pass `t as TranslateFn` through it. Imported rather than
// re-declared: a second identical type is a second contract, and the name is
// about the FUNCTION, not about files.
import { type TranslateFn } from './file-size-guard.js';

/**
* The stored shape of a `type: 'location'` value — RE-EXPORTED from
Expand DownExpand Up@@ -257,9 +264,15 @@ function draftDenotes(text: string, value: unknown): boolean {
*
* It names the format AND shows it, because the format is the whole content of
* this refusal: the pair is what the box cannot read.
*
* objectui#6755 — the sentence is a locale KEY as of the 2026-08-29 ruling, not
* a literal. The `en` value in `FIELD_DEFAULTS` is byte-identical to the literal
* it replaces, so English and provider-less rendering are unchanged and
* objectui#6716's pins keep saying exactly what they said.
*/
const REFUSED_FORMAT_MESSAGE =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
function refusedFormatMessage(t: TranslateFn): string {
return t('fields.location.refusedFormat');
}

/**
* What the box says when the pair PARSED but the platform refuses its range.
Expand All@@ -269,21 +282,27 @@ const REFUSED_FORMAT_MESSAGE =
* range is a second contract that drifts silently (AGENTS.md #0.1). The
* sentence is built from the SPEC's own issues, so the day the schema moves,
* this message moves with it.
*
* objectui#6755 keys the FRAME — the part this widget authors — and leaves
* `{{detail}}` as whatever the spec said. That division is deliberate and is
* the honest limit of this card: the interpolated complaint is the schema's own
* text, so translating it belongs to whoever owns those messages, not to a
* widget that must not restate them.
*/
function refusedRangeMessage(candidate: LocationValue): string {
function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string {
const parsed = LocationValueSchema.safeParse(candidate);
if (parsed.success) return '';
const detail = parsed.error.issues
.map(issue => `${issue.path.join('.') || 'value'}: ${issue.message}`)
.join('; ');
return `Not saved: ${detail}`;
return t('fields.location.refusedRange', { detail });
}

/**
* What the box says when a half of the pair is only PARTLY a number
* (objectui#6715).
*
* ⛔ Deliberately NOT {@link REFUSED_FORMAT_MESSAGE}. "Enter a latitude,
* ⛔ Deliberately NOT {@link refusedFormatMessage}. "Enter a latitude,
* longitude pair" is unusable advice to someone who typed `12abc, 34`: they
* DID type a pair, and that sentence gives them nothing to correct. This
* refusal names the half that could not be read and quotes it back, because
Expand All@@ -294,6 +313,12 @@ function refusedRangeMessage(candidate: LocationValue): string {
* ⛔ It does not suggest a notation to convert FROM (no `12°N` advice): the
* ruling declines that parse, so pointing at it would advertise a route this
* widget refuses.
*
* ⚠️ Still a LITERAL, alone among the three arms, and deliberately so:
* objectui#6755's ruling locks its scope to the three sentences that existed
* when it was written, and this arm landed after. objectui#6888 carries the
* gap — including the one question the other two did not have to answer, which
* is how `verb` (English grammar, not data) should be keyed.
*/
function refusedResidueMessage(residue: readonly ResidueHalf[]): string {
const named = residue.map(half => `${half.label} "${half.text}"`).join(' and ');
Expand DownExpand Up@@ -358,6 +383,10 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
* this card did not give it one.
*/
const [refusalError, setRefusalError] = useState<string | null>(null);
// objectui#6755 — the two keyed arms below read their sentences from the
// package's locale channel. Called with the other hooks, ABOVE the readonly
// early return, so hook order is the same on both branches.
const { t } = useFieldTranslation();

/**
* Adopt a value that changed OUTSIDE this box — a record finishing its load,
Expand DownExpand Up@@ -413,7 +442,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
if (parsed.kind === 'unparsable') {
// The text is not a coordinate pair. The prior value stands — and since
// objectui#6716 the box says so instead of swallowing the edit.
setRefusalError(REFUSED_FORMAT_MESSAGE);
setRefusalError(refusedFormatMessage(t as TranslateFn));
return;
}

Expand DownExpand Up@@ -443,7 +472,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
}
// objectui#6716: the refusal STANDS — this card does not reverse #6714. It
// only stops the refusal from being silent.
setRefusalError(refusedRangeMessage(emitted));
setRefusalError(refusedRangeMessage(t as TranslateFn, emitted));
};

return (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Route the three widget-local refusal sentences through the fields locale channel by os-sam · Pull Request #6890 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/6755-field-diagnostics-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Field widgets say WHY they refused an edit in the reader's language
(objectui#6755, maintainer ruling 2026-08-29).

Three sentences a person has to read to recover from a refusal were string
literals in the widgets, inside a package whose locale channel 11 of its 55
widgets already use: `ObjectField`'s `Invalid JSON`, and `LocationField`'s
format and range refusals (objectui#6716 / #6714). So a zh / ja / ar user who
mistyped a coordinate or a JSON blob was told why in English, in a form whose
labels, gate hints and validation copy were all translated.

- All three now read from `useFieldTranslation` / `FIELD_DEFAULTS` under
`fields.object.invalidJson`, `fields.location.refusedFormat` and
`fields.location.refusedRange`, with entries in all ten locale packs — bound
from now on by `check:i18n-drift`.
- The `en` values are byte-identical to the literals they replace, so English
and provider-less rendering are unchanged, and the refusal pins of
objectui#6716 / #6715 and `plugin-form`'s two refusal suites are untouched.
- `fields.location.refusedRange` keys the FRAME only: the interpolated
`{{detail}}` is `LocationValueSchema`'s own complaint, because the widget must
not restate the spec's bounds (a hand-copied range is a second contract).
- Not in scope, and recorded rather than folded in: `LocationField`'s third
refusal sentence — the residue arm objectui#6715 added after the ruling was
written — is still a literal. objectui#6888 carries it.
240 changes: 240 additions & 0 deletions packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
/**
* 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#6755 — a widget's OWN refusal sentence must reach the locale packs.
*
* `ObjectField` and `LocationField` each render a diagnostic they author
* themselves — the widget's answer to input it refused — and each was a string
* literal in the source while the same package carried a locale channel that 11
* of its 55 widgets already used. So, in the card's words, *"a zh / ja / ar user
* who mistypes a coordinate or a JSON blob is told why in English, inside a
* product whose labels, gate hints and validation copy are all translated"*. The
* defect is not that the string is English (AGENTS.md #-1 requires exactly that
* in the codebase); it is that a translatable surface was never routed through
* the channel that already existed beside it.
*
* Ruled 2026-08-29 by the maintainer: key them, ten pack entries each, bound
* from then on by `check:i18n-drift`. Scope is those THREE sentences — see
* "What is deliberately NOT here" below.
*
* ## What each group asserts, and why in this shape
*
* - **Non-`en` positive AND English-literal negative, together.** A positive-only
* assertion cannot tell a keyed sentence from one that fell back to English,
* because the fallback IS the English sentence — `createSafeTranslation`
* resolves `defaults[key]` when a pack has no entry, so a missing pack value
* renders exactly what the hard-coded literal used to render. Only the pair
* distinguishes "keyed" from "still hard-coded".
* - **`en` and provider-less are NO-OP pins, not defect reproducers.** The three
* pack values are byte-identical to the literals they replace, so English was
* green before this change too. Only a positive assertion can see that the swap
* left English alone — and provider-less rendering is what the widget tests of
* objectui#6716 / #6715 and `plugin-form`'s two refusal suites all measure.
* - **A POSITIVE CONTROL for the pack read, in this same file.** Every negative
* assertion here ("no English survives") is satisfied by a widget that renders
* NOTHING, and every positive one by a pack that happens to be loaded. So one
* test renders `AddressField`, whose `fields.address.*` keys already resolve
* through this very channel (objectui#4028), and asserts its Chinese labels in
* the same run: if the provider or the packs were not live, that control fails
* too, and a blank or English result here cannot be read as a pass.
* - **The RANGE arm's `{{detail}}` stays the spec's own words.** The widget
* builds that sentence from `LocationValueSchema`'s issues, deliberately
* (objectui#6714/#6716: a hand-copied bound is a second contract). Keying it
* therefore keys the FRAME — the part this widget authors — and the interpolated
* detail remains whatever the spec says. The zh assertion below pins exactly
* that division rather than pretending the whole sentence is translated.
*
* ## What is deliberately NOT here
*
* `LocationField`'s THIRD refusal sentence — the residue arm added by
* objectui#6715 after this card was filed and after the ruling was written — is
* still a hard-coded literal. The ruling locks scope to the three sentences it
* names, so it is reported rather than fixed here, and no assertion in this file
* pins its English text: pinning it would read as endorsement of the state the
* follow-up card exists to remove.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { valueSchemaFor } from '@objectstack/spec/data';

import { ObjectField } from '../widgets/ObjectField';
import { LocationField } from '../widgets/LocationField';
import { AddressField } from '../widgets/AddressField';

const LOCATION_SCHEMA = valueSchemaFor({ type: 'location' } as any)!;

const jsonField = { name: 'payload', label: 'Payload', type: 'object' } as any;
const locationField = { name: 'site', label: 'Site', type: 'location' } as any;
const addressField = { name: 'billing_address', type: 'address' } as any;

/** The English sentences this card keyed — the literals that used to be inline. */
const EN_INVALID_JSON = 'Invalid JSON';
const EN_REFUSED_FORMAT =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
/** The frame of the range arm; `{{detail}}` is the spec's own complaint. */
const EN_RANGE_PREFIX = 'Not saved: ';

/**
* What the SPEC says about a pair. Same oracle as
* `LocationField.refusalDiagnostic.test.tsx`: never the literal bounds, which
* would be a second contract that keeps passing on the day the schema moves.
*/
function specDetail(pair: unknown): string {
const parsed = LOCATION_SCHEMA.safeParse(pair);
if (parsed.success) throw new Error('specDetail called on a pair the spec ACCEPTS');
return parsed.error.issues
.map((i: any) => `${i.path.join('.') || 'value'}: ${i.message}`)
.join('; ');
}

/** Mount inside a provider pinned to one language, the way #4028's suite does. */
function renderIn(language: string, element: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>,
);
}

/** The widget's own diagnostic line, or `null` when it announces nothing. */
function diagnostic(container: HTMLElement): string | null {
const p = container.querySelector('p');
return p ? p.textContent : null;
}

function typeInto(container: HTMLElement, text: string) {
const control = container.querySelector('textarea') ?? container.querySelector('input');
fireEvent.change(control as HTMLElement, { target: { value: text } });
}

beforeEach(() => {
cleanup();
});

/* -------------------------------------------------------------------------- */
/* The control: a key that ALREADY resolves through this channel. */
/* -------------------------------------------------------------------------- */

describe('the locale channel is live in this run (control for objectui#6755)', () => {
it('resolves fields.address.* — a key keyed before this card — under zh', () => {
// If this fails, nothing else in this file means anything: a blank or
// English diagnostic below would be a dead provider, not a missing key.
renderIn('zh', <AddressField value={{}} onChange={vi.fn()} field={addressField} />);
expect(screen.getByLabelText('街道地址')).toBeInTheDocument();
expect(screen.getByLabelText('城市')).toBeInTheDocument();
});
});

/* -------------------------------------------------------------------------- */
/* ObjectField — "Invalid JSON". */
/* -------------------------------------------------------------------------- */

describe('ObjectField announces an unparsable draft in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it.each([
['zh', 'JSON 无效'],
['ja', 'JSON が不正です'],
['ar', 'JSON غير صالح'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(expected);
// The negative half: the fallback renders the English literal, so a
// positive-only assertion could not tell a keyed value from a missing one.
expect(container.textContent).not.toContain(EN_INVALID_JSON);
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the FORMAT refusal. */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a FORMAT refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it.each([
['zh', '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。'],
['ja', '保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。'],
['ar', 'لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(expected);
expect(container.textContent).not.toContain(EN_REFUSED_FORMAT);
// The example coordinates stay ASCII digits in every pack: they are what
// the box asks the person to TYPE, not prose.
expect(diagnostic(container)).toContain('30.2741, 120.1551');
});

it('still refuses the value it announced about', () => {
const onChange = vi.fn();
const { container } = renderIn('zh', <LocationField value={null} onChange={onChange} field={locationField} />);
typeInto(container, 'not a coordinate');
// objectui#6714/#6716's rule, unchanged by keying the sentence.
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('input')).toHaveAttribute('aria-invalid', 'true');
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the RANGE refusal (frame keyed, spec detail interpolated). */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a RANGE refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it.each([
['zh', '未保存:'],
['ja', '保存されていません: '],
['ar', 'لم يتم الحفظ: '],
])('translates the FRAME in %s and interpolates the spec\'s own complaint', (language, framePrefix) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
const detail = specDetail({ lat: 999, lng: 999 });
expect(diagnostic(container)).toBe(framePrefix + detail);
// The frame is this widget's own words and is translated; the detail is the
// spec's and is not. Pinning both halves keeps the division deliberate.
expect(container.textContent).not.toContain(EN_RANGE_PREFIX);
expect(diagnostic(container)).toContain(detail);
});
});
43 changes: 36 additions & 7 deletions packages/fields/src/widgets/LocationField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,13 @@ import { LocationValueSchema } from '@objectstack/spec/data';
import type { LocationValue } from '@objectstack/spec/data';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
// The package's declared shape for a `t` forwarded out of a component into a
// message producer — `file-size-guard.ts` exports it and `FileField` /
// `ImageField` already pass `t as TranslateFn` through it. Imported rather than
// re-declared: a second identical type is a second contract, and the name is
// about the FUNCTION, not about files.
import { type TranslateFn } from './file-size-guard.js';

/**
* The stored shape of a `type: 'location'` value — RE-EXPORTED from
Expand DownExpand Up@@ -257,9 +264,15 @@ function draftDenotes(text: string, value: unknown): boolean {
*
* It names the format AND shows it, because the format is the whole content of
* this refusal: the pair is what the box cannot read.
*
* objectui#6755 — the sentence is a locale KEY as of the 2026-08-29 ruling, not
* a literal. The `en` value in `FIELD_DEFAULTS` is byte-identical to the literal
* it replaces, so English and provider-less rendering are unchanged and
* objectui#6716's pins keep saying exactly what they said.
*/
const REFUSED_FORMAT_MESSAGE =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
function refusedFormatMessage(t: TranslateFn): string {
return t('fields.location.refusedFormat');
}

/**
* What the box says when the pair PARSED but the platform refuses its range.
Expand All@@ -269,21 +282,27 @@ const REFUSED_FORMAT_MESSAGE =
* range is a second contract that drifts silently (AGENTS.md #0.1). The
* sentence is built from the SPEC's own issues, so the day the schema moves,
* this message moves with it.
*
* objectui#6755 keys the FRAME — the part this widget authors — and leaves
* `{{detail}}` as whatever the spec said. That division is deliberate and is
* the honest limit of this card: the interpolated complaint is the schema's own
* text, so translating it belongs to whoever owns those messages, not to a
* widget that must not restate them.
*/
function refusedRangeMessage(candidate: LocationValue): string {
function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string {
const parsed = LocationValueSchema.safeParse(candidate);
if (parsed.success) return '';
const detail = parsed.error.issues
.map(issue => `${issue.path.join('.') || 'value'}: ${issue.message}`)
.join('; ');
return `Not saved: ${detail}`;
return t('fields.location.refusedRange', { detail });
}

/**
* What the box says when a half of the pair is only PARTLY a number
* (objectui#6715).
*
* ⛔ Deliberately NOT {@link REFUSED_FORMAT_MESSAGE}. "Enter a latitude,
* ⛔ Deliberately NOT {@link refusedFormatMessage}. "Enter a latitude,
* longitude pair" is unusable advice to someone who typed `12abc, 34`: they
* DID type a pair, and that sentence gives them nothing to correct. This
* refusal names the half that could not be read and quotes it back, because
Expand All@@ -294,6 +313,12 @@ function refusedRangeMessage(candidate: LocationValue): string {
* ⛔ It does not suggest a notation to convert FROM (no `12°N` advice): the
* ruling declines that parse, so pointing at it would advertise a route this
* widget refuses.
*
* ⚠️ Still a LITERAL, alone among the three arms, and deliberately so:
* objectui#6755's ruling locks its scope to the three sentences that existed
* when it was written, and this arm landed after. objectui#6888 carries the
* gap — including the one question the other two did not have to answer, which
* is how `verb` (English grammar, not data) should be keyed.
*/
function refusedResidueMessage(residue: readonly ResidueHalf[]): string {
const named = residue.map(half => `${half.label} "${half.text}"`).join(' and ');
Expand DownExpand Up@@ -358,6 +383,10 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
* this card did not give it one.
*/
const [refusalError, setRefusalError] = useState<string | null>(null);
// objectui#6755 — the two keyed arms below read their sentences from the
// package's locale channel. Called with the other hooks, ABOVE the readonly
// early return, so hook order is the same on both branches.
const { t } = useFieldTranslation();

/**
* Adopt a value that changed OUTSIDE this box — a record finishing its load,
Expand DownExpand Up@@ -413,7 +442,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
if (parsed.kind === 'unparsable') {
// The text is not a coordinate pair. The prior value stands — and since
// objectui#6716 the box says so instead of swallowing the edit.
setRefusalError(REFUSED_FORMAT_MESSAGE);
setRefusalError(refusedFormatMessage(t as TranslateFn));
return;
}

Expand DownExpand Up@@ -443,7 +472,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
}
// objectui#6716: the refusal STANDS — this card does not reverse #6714. It
// only stops the refusal from being silent.
setRefusalError(refusedRangeMessage(emitted));
setRefusalError(refusedRangeMessage(t as TranslateFn, emitted));
};

return (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Route the three widget-local refusal sentences through the fields locale channel by os-sam · Pull Request #6890 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/6755-field-diagnostics-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Field widgets say WHY they refused an edit in the reader's language
(objectui#6755, maintainer ruling 2026-08-29).

Three sentences a person has to read to recover from a refusal were string
literals in the widgets, inside a package whose locale channel 11 of its 55
widgets already use: `ObjectField`'s `Invalid JSON`, and `LocationField`'s
format and range refusals (objectui#6716 / #6714). So a zh / ja / ar user who
mistyped a coordinate or a JSON blob was told why in English, in a form whose
labels, gate hints and validation copy were all translated.

- All three now read from `useFieldTranslation` / `FIELD_DEFAULTS` under
`fields.object.invalidJson`, `fields.location.refusedFormat` and
`fields.location.refusedRange`, with entries in all ten locale packs — bound
from now on by `check:i18n-drift`.
- The `en` values are byte-identical to the literals they replace, so English
and provider-less rendering are unchanged, and the refusal pins of
objectui#6716 / #6715 and `plugin-form`'s two refusal suites are untouched.
- `fields.location.refusedRange` keys the FRAME only: the interpolated
`{{detail}}` is `LocationValueSchema`'s own complaint, because the widget must
not restate the spec's bounds (a hand-copied range is a second contract).
- Not in scope, and recorded rather than folded in: `LocationField`'s third
refusal sentence — the residue arm objectui#6715 added after the ruling was
written — is still a literal. objectui#6888 carries it.
240 changes: 240 additions & 0 deletions packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
/**
* 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#6755 — a widget's OWN refusal sentence must reach the locale packs.
*
* `ObjectField` and `LocationField` each render a diagnostic they author
* themselves — the widget's answer to input it refused — and each was a string
* literal in the source while the same package carried a locale channel that 11
* of its 55 widgets already used. So, in the card's words, *"a zh / ja / ar user
* who mistypes a coordinate or a JSON blob is told why in English, inside a
* product whose labels, gate hints and validation copy are all translated"*. The
* defect is not that the string is English (AGENTS.md #-1 requires exactly that
* in the codebase); it is that a translatable surface was never routed through
* the channel that already existed beside it.
*
* Ruled 2026-08-29 by the maintainer: key them, ten pack entries each, bound
* from then on by `check:i18n-drift`. Scope is those THREE sentences — see
* "What is deliberately NOT here" below.
*
* ## What each group asserts, and why in this shape
*
* - **Non-`en` positive AND English-literal negative, together.** A positive-only
* assertion cannot tell a keyed sentence from one that fell back to English,
* because the fallback IS the English sentence — `createSafeTranslation`
* resolves `defaults[key]` when a pack has no entry, so a missing pack value
* renders exactly what the hard-coded literal used to render. Only the pair
* distinguishes "keyed" from "still hard-coded".
* - **`en` and provider-less are NO-OP pins, not defect reproducers.** The three
* pack values are byte-identical to the literals they replace, so English was
* green before this change too. Only a positive assertion can see that the swap
* left English alone — and provider-less rendering is what the widget tests of
* objectui#6716 / #6715 and `plugin-form`'s two refusal suites all measure.
* - **A POSITIVE CONTROL for the pack read, in this same file.** Every negative
* assertion here ("no English survives") is satisfied by a widget that renders
* NOTHING, and every positive one by a pack that happens to be loaded. So one
* test renders `AddressField`, whose `fields.address.*` keys already resolve
* through this very channel (objectui#4028), and asserts its Chinese labels in
* the same run: if the provider or the packs were not live, that control fails
* too, and a blank or English result here cannot be read as a pass.
* - **The RANGE arm's `{{detail}}` stays the spec's own words.** The widget
* builds that sentence from `LocationValueSchema`'s issues, deliberately
* (objectui#6714/#6716: a hand-copied bound is a second contract). Keying it
* therefore keys the FRAME — the part this widget authors — and the interpolated
* detail remains whatever the spec says. The zh assertion below pins exactly
* that division rather than pretending the whole sentence is translated.
*
* ## What is deliberately NOT here
*
* `LocationField`'s THIRD refusal sentence — the residue arm added by
* objectui#6715 after this card was filed and after the ruling was written — is
* still a hard-coded literal. The ruling locks scope to the three sentences it
* names, so it is reported rather than fixed here, and no assertion in this file
* pins its English text: pinning it would read as endorsement of the state the
* follow-up card exists to remove.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { valueSchemaFor } from '@objectstack/spec/data';

import { ObjectField } from '../widgets/ObjectField';
import { LocationField } from '../widgets/LocationField';
import { AddressField } from '../widgets/AddressField';

const LOCATION_SCHEMA = valueSchemaFor({ type: 'location' } as any)!;

const jsonField = { name: 'payload', label: 'Payload', type: 'object' } as any;
const locationField = { name: 'site', label: 'Site', type: 'location' } as any;
const addressField = { name: 'billing_address', type: 'address' } as any;

/** The English sentences this card keyed — the literals that used to be inline. */
const EN_INVALID_JSON = 'Invalid JSON';
const EN_REFUSED_FORMAT =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
/** The frame of the range arm; `{{detail}}` is the spec's own complaint. */
const EN_RANGE_PREFIX = 'Not saved: ';

/**
* What the SPEC says about a pair. Same oracle as
* `LocationField.refusalDiagnostic.test.tsx`: never the literal bounds, which
* would be a second contract that keeps passing on the day the schema moves.
*/
function specDetail(pair: unknown): string {
const parsed = LOCATION_SCHEMA.safeParse(pair);
if (parsed.success) throw new Error('specDetail called on a pair the spec ACCEPTS');
return parsed.error.issues
.map((i: any) => `${i.path.join('.') || 'value'}: ${i.message}`)
.join('; ');
}

/** Mount inside a provider pinned to one language, the way #4028's suite does. */
function renderIn(language: string, element: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>,
);
}

/** The widget's own diagnostic line, or `null` when it announces nothing. */
function diagnostic(container: HTMLElement): string | null {
const p = container.querySelector('p');
return p ? p.textContent : null;
}

function typeInto(container: HTMLElement, text: string) {
const control = container.querySelector('textarea') ?? container.querySelector('input');
fireEvent.change(control as HTMLElement, { target: { value: text } });
}

beforeEach(() => {
cleanup();
});

/* -------------------------------------------------------------------------- */
/* The control: a key that ALREADY resolves through this channel. */
/* -------------------------------------------------------------------------- */

describe('the locale channel is live in this run (control for objectui#6755)', () => {
it('resolves fields.address.* — a key keyed before this card — under zh', () => {
// If this fails, nothing else in this file means anything: a blank or
// English diagnostic below would be a dead provider, not a missing key.
renderIn('zh', <AddressField value={{}} onChange={vi.fn()} field={addressField} />);
expect(screen.getByLabelText('街道地址')).toBeInTheDocument();
expect(screen.getByLabelText('城市')).toBeInTheDocument();
});
});

/* -------------------------------------------------------------------------- */
/* ObjectField — "Invalid JSON". */
/* -------------------------------------------------------------------------- */

describe('ObjectField announces an unparsable draft in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(EN_INVALID_JSON);
});

it.each([
['zh', 'JSON 无效'],
['ja', 'JSON が不正です'],
['ar', 'JSON غير صالح'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <ObjectField value={null} onChange={vi.fn()} field={jsonField} />);
typeInto(container, '{ not json');
expect(diagnostic(container)).toBe(expected);
// The negative half: the fallback renders the English literal, so a
// positive-only assertion could not tell a keyed value from a missing one.
expect(container.textContent).not.toContain(EN_INVALID_JSON);
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the FORMAT refusal. */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a FORMAT refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(EN_REFUSED_FORMAT);
});

it.each([
['zh', '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。'],
['ja', '保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。'],
['ar', 'لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).'],
])('says it in %s, with no English literal left behind', (language, expected) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, 'not a coordinate');
expect(diagnostic(container)).toBe(expected);
expect(container.textContent).not.toContain(EN_REFUSED_FORMAT);
// The example coordinates stay ASCII digits in every pack: they are what
// the box asks the person to TYPE, not prose.
expect(diagnostic(container)).toContain('30.2741, 120.1551');
});

it('still refuses the value it announced about', () => {
const onChange = vi.fn();
const { container } = renderIn('zh', <LocationField value={null} onChange={onChange} field={locationField} />);
typeInto(container, 'not a coordinate');
// objectui#6714/#6716's rule, unchanged by keying the sentence.
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('input')).toHaveAttribute('aria-invalid', 'true');
});
});

/* -------------------------------------------------------------------------- */
/* LocationField — the RANGE refusal (frame keyed, spec detail interpolated). */
/* -------------------------------------------------------------------------- */

describe('LocationField announces a RANGE refusal in the reader\'s language (objectui#6755)', () => {
it('keeps the English sentence byte-identical under an en provider', () => {
const { container } = renderIn('en', <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it('keeps the English sentence byte-identical with NO provider at all', () => {
const { container } = render(<LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
expect(diagnostic(container)).toBe(EN_RANGE_PREFIX + specDetail({ lat: 999, lng: 999 }));
});

it.each([
['zh', '未保存:'],
['ja', '保存されていません: '],
['ar', 'لم يتم الحفظ: '],
])('translates the FRAME in %s and interpolates the spec\'s own complaint', (language, framePrefix) => {
const { container } = renderIn(language, <LocationField value={null} onChange={vi.fn()} field={locationField} />);
typeInto(container, '999, 999');
const detail = specDetail({ lat: 999, lng: 999 });
expect(diagnostic(container)).toBe(framePrefix + detail);
// The frame is this widget's own words and is translated; the detail is the
// spec's and is not. Pinning both halves keeps the division deliberate.
expect(container.textContent).not.toContain(EN_RANGE_PREFIX);
expect(diagnostic(container)).toContain(detail);
});
});
43 changes: 36 additions & 7 deletions packages/fields/src/widgets/LocationField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,13 @@ import { LocationValueSchema } from '@objectstack/spec/data';
import type { LocationValue } from '@objectstack/spec/data';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
// The package's declared shape for a `t` forwarded out of a component into a
// message producer — `file-size-guard.ts` exports it and `FileField` /
// `ImageField` already pass `t as TranslateFn` through it. Imported rather than
// re-declared: a second identical type is a second contract, and the name is
// about the FUNCTION, not about files.
import { type TranslateFn } from './file-size-guard.js';

/**
* The stored shape of a `type: 'location'` value — RE-EXPORTED from
Expand DownExpand Up@@ -257,9 +264,15 @@ function draftDenotes(text: string, value: unknown): boolean {
*
* It names the format AND shows it, because the format is the whole content of
* this refusal: the pair is what the box cannot read.
*
* objectui#6755 — the sentence is a locale KEY as of the 2026-08-29 ruling, not
* a literal. The `en` value in `FIELD_DEFAULTS` is byte-identical to the literal
* it replaces, so English and provider-less rendering are unchanged and
* objectui#6716's pins keep saying exactly what they said.
*/
const REFUSED_FORMAT_MESSAGE =
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).';
function refusedFormatMessage(t: TranslateFn): string {
return t('fields.location.refusedFormat');
}

/**
* What the box says when the pair PARSED but the platform refuses its range.
Expand All@@ -269,21 +282,27 @@ const REFUSED_FORMAT_MESSAGE =
* range is a second contract that drifts silently (AGENTS.md #0.1). The
* sentence is built from the SPEC's own issues, so the day the schema moves,
* this message moves with it.
*
* objectui#6755 keys the FRAME — the part this widget authors — and leaves
* `{{detail}}` as whatever the spec said. That division is deliberate and is
* the honest limit of this card: the interpolated complaint is the schema's own
* text, so translating it belongs to whoever owns those messages, not to a
* widget that must not restate them.
*/
function refusedRangeMessage(candidate: LocationValue): string {
function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string {
const parsed = LocationValueSchema.safeParse(candidate);
if (parsed.success) return '';
const detail = parsed.error.issues
.map(issue => `${issue.path.join('.') || 'value'}: ${issue.message}`)
.join('; ');
return `Not saved: ${detail}`;
return t('fields.location.refusedRange', { detail });
}

/**
* What the box says when a half of the pair is only PARTLY a number
* (objectui#6715).
*
* ⛔ Deliberately NOT {@link REFUSED_FORMAT_MESSAGE}. "Enter a latitude,
* ⛔ Deliberately NOT {@link refusedFormatMessage}. "Enter a latitude,
* longitude pair" is unusable advice to someone who typed `12abc, 34`: they
* DID type a pair, and that sentence gives them nothing to correct. This
* refusal names the half that could not be read and quotes it back, because
Expand All@@ -294,6 +313,12 @@ function refusedRangeMessage(candidate: LocationValue): string {
* ⛔ It does not suggest a notation to convert FROM (no `12°N` advice): the
* ruling declines that parse, so pointing at it would advertise a route this
* widget refuses.
*
* ⚠️ Still a LITERAL, alone among the three arms, and deliberately so:
* objectui#6755's ruling locks its scope to the three sentences that existed
* when it was written, and this arm landed after. objectui#6888 carries the
* gap — including the one question the other two did not have to answer, which
* is how `verb` (English grammar, not data) should be keyed.
*/
function refusedResidueMessage(residue: readonly ResidueHalf[]): string {
const named = residue.map(half => `${half.label} "${half.text}"`).join(' and ');
Expand DownExpand Up@@ -358,6 +383,10 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
* this card did not give it one.
*/
const [refusalError, setRefusalError] = useState<string | null>(null);
// objectui#6755 — the two keyed arms below read their sentences from the
// package's locale channel. Called with the other hooks, ABOVE the readonly
// early return, so hook order is the same on both branches.
const { t } = useFieldTranslation();

/**
* Adopt a value that changed OUTSIDE this box — a record finishing its load,
Expand DownExpand Up@@ -413,7 +442,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
if (parsed.kind === 'unparsable') {
// The text is not a coordinate pair. The prior value stands — and since
// objectui#6716 the box says so instead of swallowing the edit.
setRefusalError(REFUSED_FORMAT_MESSAGE);
setRefusalError(refusedFormatMessage(t as TranslateFn));
return;
}

Expand DownExpand Up@@ -443,7 +472,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
}
// objectui#6716: the refusal STANDS — this card does not reverse #6714. It
// only stops the refusal from being silent.
setRefusalError(refusedRangeMessage(emitted));
setRefusalError(refusedRangeMessage(t as TranslateFn, emitted));
};

return (
Expand Down
Loading
Loading