diff --git a/.changeset/6755-field-diagnostics-i18n.md b/.changeset/6755-field-diagnostics-i18n.md
new file mode 100644
index 000000000..06b1c9735
--- /dev/null
+++ b/.changeset/6755-field-diagnostics-i18n.md
@@ -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.
diff --git a/packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx b/packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
new file mode 100644
index 000000000..efda3f8f0
--- /dev/null
+++ b/packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx
@@ -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(
+
+ {element}
+ ,
+ );
+}
+
+/** 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', );
+ 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', );
+ 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();
+ 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, );
+ 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', );
+ 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();
+ 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, );
+ 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', );
+ 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', );
+ 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();
+ 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, );
+ 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);
+ });
+});
diff --git a/packages/fields/src/widgets/LocationField.tsx b/packages/fields/src/widgets/LocationField.tsx
index f7d32425f..9ecb87861 100644
--- a/packages/fields/src/widgets/LocationField.tsx
+++ b/packages/fields/src/widgets/LocationField.tsx
@@ -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
@@ -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.
@@ -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
@@ -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 ');
@@ -358,6 +383,10 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
* this card did not give it one.
*/
const [refusalError, setRefusalError] = useState(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,
@@ -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;
}
@@ -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 (
diff --git a/packages/fields/src/widgets/ObjectField.tsx b/packages/fields/src/widgets/ObjectField.tsx
index 778cc8b95..57a5a2438 100644
--- a/packages/fields/src/widgets/ObjectField.tsx
+++ b/packages/fields/src/widgets/ObjectField.tsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Textarea, cn, EmptyValue } from '@object-ui/components';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
+import { useFieldTranslation } from './useFieldTranslation.js';
/**
* ObjectField - JSON object editor
@@ -20,6 +21,11 @@ export function ObjectField({ value, onChange, field, readonly, error, ...props
// Named `parseError`, NOT `error`: `error` is the published validation slot
// on the widget contract (#3222) and is destructured above.
const [parseError, setParseError] = useState(null);
+ // objectui#6755 — this widget's ONE diagnostic sentence goes through the
+ // package's locale channel, the same one 11 of its 55 widgets already read.
+ // Called here, ABOVE the readonly early return below, so hook order is the
+ // same on both branches (the `AddressField` discipline).
+ const { t } = useFieldTranslation();
// Sync internal string state when value changes externally
// This is a controlled component pattern where we need to sync external changes
@@ -65,8 +71,15 @@ export function ObjectField({ value, onChange, field, readonly, error, ...props
const parsed = JSON.parse(str);
onChange(parsed);
} catch (e) {
- // Invalid JSON - don't propagate change to parent, but keep local state
- setParseError("Invalid JSON");
+ // Invalid JSON - don't propagate change to parent, but keep local state.
+ // The sentence is produced HERE, when the draft is refused, rather than
+ // at render: `LocationField`'s two arms carry data measured at refusal
+ // time (the spec's complaint about the pair that was refused), and the
+ // two widgets say their refusals the same way on purpose. The cost is
+ // stated rather than hidden: a language switched WHILE a refusal is lit
+ // leaves that one sentence in the language it was produced in until the
+ // next keystroke.
+ setParseError(t('fields.object.invalidJson'));
}
};
diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts
index df1534fa3..bb6fe1fda 100644
--- a/packages/fields/src/widgets/useFieldTranslation.ts
+++ b/packages/fields/src/widgets/useFieldTranslation.ts
@@ -87,6 +87,26 @@ const FIELD_DEFAULTS: Record = {
'fields.filterCondition.jsonOnly': 'This criteria can only be edited as JSON',
'fields.filterCondition.editAsJson': 'Edit as JSON',
'fields.filterCondition.useVisualBuilder': 'Use visual builder',
+ // objectui#6755 — the two widgets whose OWN refusal sentence was a string
+ // literal: `ObjectField`'s unparsable draft and `LocationField`'s format and
+ // range refusals (objectui#6716/#6714). Every one of them is what a person
+ // reads to recover from an edit the widget declined, and none of them could
+ // be reached by a locale — inside a package 11 of whose 55 widgets already
+ // read this map.
+ //
+ // Values are byte-identical to the literals they replace, so English and
+ // provider-less rendering are unchanged and the pins in
+ // `LocationField.refusalDiagnostic.test.tsx` and `plugin-form`'s two refusal
+ // suites keep asserting what they asserted.
+ //
+ // `refusedRange` carries the spec's OWN complaint in `{{detail}}`: the widget
+ // refuses to restate `LocationValueSchema`'s bounds (a hand-copied range is a
+ // second contract), so this key translates the frame and interpolates what
+ // the schema said.
+ 'fields.object.invalidJson': 'Invalid JSON',
+ 'fields.location.refusedFormat':
+ 'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).',
+ 'fields.location.refusedRange': 'Not saved: {{detail}}',
// objectui#3342 — the tags widget's input hint. Used only when the field
// author declared no `placeholder` of their own (author declaration wins).
'fields.tags.placeholder': 'Type and press Enter to add…',
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 0e2750b86..1c004e131 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -264,6 +264,14 @@ const ar = {
editAsJson: "التحرير بصيغة JSON",
useVisualBuilder: "استخدام المُنشئ المرئي",
},
+ object: {
+ invalidJson: "JSON غير صالح",
+ },
+ location: {
+ refusedFormat:
+ "لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).",
+ refusedRange: "لم يتم الحفظ: {{detail}}",
+ },
tags: {
placeholder: "اكتب واضغط Enter للإضافة…",
},
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index 8384cadc1..f37b9428a 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -260,6 +260,14 @@ const de = {
editAsJson: "Als JSON bearbeiten",
useVisualBuilder: "Visuellen Builder verwenden",
},
+ object: {
+ invalidJson: "Ungültiges JSON",
+ },
+ location: {
+ refusedFormat:
+ "Nicht gespeichert: Geben Sie ein Paar aus Breitengrad, Längengrad ein (Beispiel: 30.2741, 120.1551).",
+ refusedRange: "Nicht gespeichert: {{detail}}",
+ },
tags: {
placeholder: "Tippen und mit der Eingabetaste hinzufügen…",
},
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 09c23dbb7..0fdd7d120 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -296,6 +296,39 @@ const en = {
editAsJson: 'Edit as JSON',
useVisualBuilder: 'Use visual builder',
},
+ // objectui#6755 — a widget's OWN refusal sentence: `ObjectField`'s
+ // unparsable JSON draft, and `LocationField`'s format and range refusals
+ // (objectui#6716 / #6714). They were string literals in the widgets, so a
+ // zh / ja / ar user who mistyped a coordinate or a JSON blob was told why in
+ // English inside a form whose every other word was translated.
+ //
+ // KEYED, where objectui#4028 DROPPED the five address placeholders in the
+ // namespace below — and the difference is what that decision's own comment
+ // records about them: "The right example is a function of the address's
+ // COUNTRY, not the reader's language", and "Each box here already has a
+ // visible label naming exactly what it wants". Neither reaches a refusal
+ // sentence: nothing else on the screen says why the edit was refused, and
+ // what that sentence must say IS a function of the reader's language. What
+ // #4028 does establish for both is the COST — ten pack entries per key,
+ // bound from then on by `check:i18n-drift` — and the 2026-08-29 maintainer
+ // ruling accepted that cost for these three sentences.
+ //
+ // Values are byte-identical to the literals they replace (`FIELD_DEFAULTS`
+ // in `packages/fields/src/widgets/useFieldTranslation.ts` carries the same
+ // three), so English and provider-less rendering are unchanged.
+ //
+ // `{{detail}}` is the SPEC's own complaint about the refused pair.
+ // `LocationField` refuses to restate `LocationValueSchema`'s bounds — a
+ // hand-copied range is a second contract — so this key translates the frame
+ // and interpolates whatever the schema said.
+ object: {
+ invalidJson: 'Invalid JSON',
+ },
+ location: {
+ refusedFormat:
+ 'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).',
+ refusedRange: 'Not saved: {{detail}}',
+ },
// objectui#3342 — the tags widget's input hint, shown while the tag list
// is empty. The author-declared `field.placeholder` always wins over this.
tags: {
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index 37fbba5ad..8774388b1 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -264,6 +264,14 @@ const es = {
editAsJson: "Editar como JSON",
useVisualBuilder: "Usar el constructor visual",
},
+ object: {
+ invalidJson: "JSON no válido",
+ },
+ location: {
+ refusedFormat:
+ "No guardado: introduce un par latitud, longitud (ejemplo: 30.2741, 120.1551).",
+ refusedRange: "No guardado: {{detail}}",
+ },
tags: {
placeholder: "Escriba y pulse Intro para añadir…",
},
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index a6293c053..90ae4ce26 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -260,6 +260,14 @@ const fr = {
editAsJson: "Modifier en JSON",
useVisualBuilder: "Utiliser le constructeur visuel",
},
+ object: {
+ invalidJson: "JSON invalide",
+ },
+ location: {
+ refusedFormat:
+ "Non enregistré : saisissez une paire latitude, longitude (exemple : 30.2741, 120.1551).",
+ refusedRange: "Non enregistré : {{detail}}",
+ },
tags: {
placeholder: "Saisissez puis appuyez sur Entrée pour ajouter…",
},
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 0f9c17887..68ced4925 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -260,6 +260,14 @@ const ja = {
editAsJson: "JSON で編集",
useVisualBuilder: "ビジュアルビルダーを使用",
},
+ object: {
+ invalidJson: "JSON が不正です",
+ },
+ location: {
+ refusedFormat:
+ "保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。",
+ refusedRange: "保存されていません: {{detail}}",
+ },
tags: {
placeholder: "入力してEnterキーで追加…",
},
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index 283a888b6..8761a9d6e 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -260,6 +260,14 @@ const ko = {
editAsJson: "JSON으로 편집",
useVisualBuilder: "비주얼 빌더 사용",
},
+ object: {
+ invalidJson: "잘못된 JSON",
+ },
+ location: {
+ refusedFormat:
+ "저장되지 않았습니다: 위도, 경도 쌍으로 입력하세요(예: 30.2741, 120.1551).",
+ refusedRange: "저장되지 않았습니다: {{detail}}",
+ },
tags: {
placeholder: "입력 후 Enter 키로 추가…",
},
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 40ab35546..82e20e97e 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -259,6 +259,14 @@ const pt = {
editAsJson: "Editar como JSON",
useVisualBuilder: "Usar o construtor visual",
},
+ object: {
+ invalidJson: "JSON inválido",
+ },
+ location: {
+ refusedFormat:
+ "Não salvo: informe um par latitude, longitude (exemplo: 30.2741, 120.1551).",
+ refusedRange: "Não salvo: {{detail}}",
+ },
tags: {
placeholder: "Digite e pressione Enter para adicionar…",
},
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index 6259301df..aae1608c9 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -266,6 +266,14 @@ const ru = {
editAsJson: "Редактировать как JSON",
useVisualBuilder: "Использовать визуальный конструктор",
},
+ object: {
+ invalidJson: "Некорректный JSON",
+ },
+ location: {
+ refusedFormat:
+ "Не сохранено: введите пару широта, долгота (например: 30.2741, 120.1551).",
+ refusedRange: "Не сохранено: {{detail}}",
+ },
tags: {
placeholder: "Введите и нажмите Enter, чтобы добавить…",
},
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index fecf32f15..0c643d941 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -267,6 +267,14 @@ const zh = {
editAsJson: '以 JSON 编辑',
useVisualBuilder: '使用可视化构建器',
},
+ object: {
+ invalidJson: 'JSON 无效',
+ },
+ location: {
+ refusedFormat:
+ '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。',
+ refusedRange: '未保存:{{detail}}',
+ },
tags: {
placeholder: '输入后回车添加…',
},