From 4be2007b5ebdfb7b606b49eafa9a110fb1a192d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:41:35 +0000 Subject: [PATCH] i18n(fields): route the three widget-local refusal sentences through the locale channel `ObjectField`'s "Invalid JSON" and `LocationField`'s format and range refusals were string literals in the widgets, inside a package whose locale channel 11 of its 55 widgets already read. 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. The `en` values are byte-identical to the literals they replace, so English and provider-less rendering are unchanged. `fields.location.refusedRange` keys the FRAME only: `{{detail}}` stays `LocationValueSchema`'s own complaint, because the widget must not restate the spec's bounds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/6755-field-diagnostics-i18n.md | 28 ++ .../widget-diagnostics-i18n-6755.test.tsx | 240 ++++++++++++++++++ packages/fields/src/widgets/LocationField.tsx | 43 +++- packages/fields/src/widgets/ObjectField.tsx | 17 +- .../fields/src/widgets/useFieldTranslation.ts | 20 ++ packages/i18n/src/locales/ar.ts | 8 + packages/i18n/src/locales/de.ts | 8 + packages/i18n/src/locales/en.ts | 33 +++ packages/i18n/src/locales/es.ts | 8 + packages/i18n/src/locales/fr.ts | 8 + packages/i18n/src/locales/ja.ts | 8 + packages/i18n/src/locales/ko.ts | 8 + packages/i18n/src/locales/pt.ts | 8 + packages/i18n/src/locales/ru.ts | 8 + packages/i18n/src/locales/zh.ts | 8 + 15 files changed, 444 insertions(+), 9 deletions(-) create mode 100644 .changeset/6755-field-diagnostics-i18n.md create mode 100644 packages/fields/src/__tests__/widget-diagnostics-i18n-6755.test.tsx diff --git a/.changeset/6755-field-diagnostics-i18n.md b/.changeset/6755-field-diagnostics-i18n.md new file mode 100644 index 0000000000..06b1c97359 --- /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 0000000000..efda3f8f04 --- /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 f7d32425f4..9ecb878610 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 778cc8b953..57a5a24380 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 df1534fa39..bb6fe1fdab 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 0e2750b86c..1c004e131c 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 8384cadc1b..f37b9428ac 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 09c23dbb77..0fdd7d120a 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 37fbba5ad2..8774388b11 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 a6293c053d..90ae4ce26b 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 0f9c178871..68ced49250 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 283a888b6a..8761a9d6eb 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 40ab35546e..82e20e97e3 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 6259301df0..aae1608c9a 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 fecf32f15a..0c643d941e 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: '输入后回车添加…', },