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
65 changes: 65 additions & 0 deletions .changeset/draftbar-es-register-3844.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
'@object-ui/i18n': patch
---

`preview.draftBar` speaks one second person in `es` — the draft-preview banner no
longer switches from tú to usted when a Spanish user publishes (#3844)

`DraftPreviewBar` renders two mutually exclusive sentences in the same strip of
the same banner: `message` while there are unpublished changes, `messageClean`
once there are none. In `es` the two disagreed on register — `message` was tú
(`estás viendo`, `publiques`) while `messageClean` (`ve`) and `sampleDataBody`
(`Está`, `su`, `Publíquela`) were usted. So a Spanish user who pressed Publish
watched the banner change person: same component, same position, same session.

This is a third defect class in the value-domain blind spot behind #3582 and
#3625, and no gate in the repo can see it. Both `es` values are correct
translations of their `en` sentences — nothing is missing, nothing is stale, and
nothing holds English. The inconsistency is *internal to one pack, on one UI
surface*: `scripts/check-i18n-call-site-keys.mjs` only asks whether a key exists
in `en`, `all-locales-key-parity` compares key sets and placeholder shapes and
never reads values, and `scripts/check-i18n-en-drift.mjs` only fires when an `en`
value moves — these two `en` values never moved.

`message` is the value that changes, because usted is what the pack already says
everywhere around it: the `es` pack censuses 102 usted markers to 30 tú (tú being
the marked exception, concentrated in the auth, report-editor and organizations
neighbourhoods that #3546 slice two deliberately ruled informal); the other three
strings of this same object were already usted; and #3546 slice five gave the 19
new `preview.unpublishedBar.*` / `preview.history.*` keys usted on the strength
of `home.pendingDrafts.published` ("¡Publicado! Sus cambios están activos."). Two
smaller divergences inside the same sentence are closed with it, so the banner's
two halves stop disagreeing about wording as well as person:

- **`—` instead of `:`** in `messageClean`, matching `en`, where both sentences
open `Draft preview — `. That is the whole of `messageClean`'s diff.
- **`activo` instead of `en producción`** for "live". The pack spells this concept
`activo` in four neighbouring places including `publishCta` in this very object
("Publicar para verlo activo"), and it reserves *producción* for the actual
production environment (`environment.entitlement.planLockedBody`), so the
outlier was ambiguous as well as inconsistent.
- **`Vista previa del borrador`** as the shared opening, `del` being the form
`messageClean` already used.

No `en` value changes (the en-drift gate reports 0), no key is added or removed
(so `all-locales-key-parity` is untouched by construction), and the nine other
packs are not touched. The diff is two values in one file.

Re-voicing the whole `preview` namespace to tú was considered and rejected — a
much larger change that would collide with the adjacent `marketplace.*` (9:0
usted) and `console.ai.*`. `preview.empty.notReadyDescription` therefore stays tú
here, since #3844's ruling is this banner only; it is filed as #3875 rather than
waved through, because `PreviewDraftEmptyState` renders *underneath*
`DraftPreviewBar` and so those two show usted and tú on screen at the same time.
After this change the `preview` namespace is 23 usted to that 1 tú.

A gate that checks "one register per namespace" is deliberately **not** here:
recognising usted vs tú needs real morphology, and token matching demonstrably
cannot — in this pack the token `revisa` is usted in `console.ai.empty.build`
(es.ts:1365, "…y usted revisa y publica.") and tú in
`auth.forgotPassword.successTitle` (es.ts:1918, "Revisa tu correo electrónico"),
the same eight letters in opposite registers. The neighbourhood boundary such a
gate would police is human judgement anyway — #3546 slice two's "same rule,
different answer". A new `draftBar-es-register-3844.test.ts` pins the four `es`
values byte for byte instead, plus the `en` literals, so a future reword of
either `en` sentence fails in the same PR that reworded it.
209 changes: 209 additions & 0 deletions packages/i18n/src/__tests__/draftBar-es-register-3844.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* `preview.draftBar` speaks ONE second person in `es` — usted (objectui#3844).
*
* ## The defect this pins
*
* `DraftPreviewBar` renders two mutually exclusive sentences in the same strip
* of the same banner: `message` when there are unpublished changes,
* `messageClean` when there are none. In `es` the two disagreed on register —
* `message` was tú (`estás viendo`, `publiques`) while `messageClean` (`ve`)
* and `sampleDataBody` (`Está`, `su`, `Publíquela`) were usted. A Spanish user
* who pressed Publish watched the banner switch from tú to usted: same
* component, same position, same session.
*
* ## Why usted is the answer here (the ruling, not a preference)
*
* Three independent readings, all measured rather than guessed:
*
* 1. **Pack default.** The `es` pack censuses 102 usted markers against 30
* tú. tú is the marked exception, concentrated in auth sign-in/sign-up,
* the report editor and organizations — neighbourhoods objectui#3546
* slice two deliberately ruled informal. `preview` is not one of them.
* 2. **Same banner, same voice.** The other two strings of this very object
* (`messageClean`, `sampleDataBody`) were already usted, and so is
* `publishCta`. Aligning the one outlier is a strictly smaller change
* than re-voicing the namespace, and it is the change that removes the
* user-visible switch.
* 3. **The sister banner.** objectui#3546 slice five gave the 19 new
* `preview.unpublishedBar.*` / `preview.history.*` keys usted, on the
* strength of `home.pendingDrafts.published` ("¡Publicado! Sus cambios
* están activos."), the structural twin of
* `preview.unpublishedBar.published`. Leaving `draftBar.message` tú would
* have made the older banner disagree with its freshly-translated sister.
*
* Direction B (re-voicing all of `preview` to tú) was rejected: it is a much
* larger change and would collide with the adjacent `marketplace.*` (9:0
* usted) and `console.ai.*`.
*
* ## What this file deliberately does NOT do
*
* **No morphological register detection.** objectui#3844 evaluated a gate that
* checks "one register per namespace" and argued it down. Recognising usted vs
* tú needs real morphology, and token matching demonstrably cannot: in this
* very pack the token `revisa` is **usted** in `console.ai.empty.build`
* (es.ts:1365, "…y usted revisa y publica.", present indicative with an
* explicit `usted`) and **tú** in `auth.forgotPassword.successTitle`
* (es.ts:1918, "Revisa tu correo electrónico", imperative). Same eight
* letters, opposite registers, and no regex over the string can tell them
* apart. The reverse error is just as easy: third-person and impersonal forms
* read as usted markers — a crude `puede|su|…` scan over `approvalsInbox`
* returns five "usted" hits that are all about *el solicitante* or are
* impersonal ("No se puede determinar la identidad"), in a namespace whose
* second-person address is in fact uniformly tú (nine strings). objectui#3844
* recorded this hazard as "the census regex miscounted `Revisa`"; the
* measurement above is the precise version — the miscount is the usted
* indicative at :1365, not an imperative. Worse, the boundary such a gate
* would police — which neighbourhood is informal — is human judgement (slice
* two's "same rule, different answer" precedent), so a gate could only ever
* check consistency *within* a boundary it cannot draw.
* This file therefore pins **these four literal values, byte for byte**, and
* generalises to nothing. The retired-spelling checks below are exact
* substrings lifted from the pre-fix value of this one key — the same
* technique as `viewReadonlyTooltip-semantics-3625.test.ts` — not a register
* rule in disguise.
*
* Scope note: `preview.empty.notReadyDescription` (es.ts:3286, `Revisa`) is
* still tú and is left that way here, because re-voicing the namespace is the
* rejected direction B and objectui#3844's ruling is this banner only. It is
* **not** harmless, and this file does not pin it: `PreviewDraftEmptyState`
* (`AppContent.tsx`, returned while `previewDrafts` and the app is not yet
* staged) renders in the content area *underneath* `DraftPreviewBar`
* (`ConsoleLayout.tsx`), so those two are on screen at the SAME time — usted in
* the bar, tú in the pane below it. That is sharper than objectui#3844's own
* sequential switch and is filed separately as objectui#3875. After this change
* the `preview` namespace is 23 usted to that 1 tú.
*
* Related: objectui#3546 (slice five measured this while ruling es register),
* objectui#3810 / objectui#3582 / objectui#3625 (the other value-domain blind
* spots), objectui#3530 (call-site gate), objectui#3650 (en-drift gate). None
* of the three i18n gates can see this defect: the call-site gate only asks
* whether a key exists in `en`, `all-locales-key-parity` compares key names
* and placeholder shapes and never reads values, and the en-drift gate only
* fires when an `en` value moves — these two `en` values never moved.
*/
import { describe, it, expect } from 'vitest';
import { builtInLocales } from '../locales';

type Bag = Record<string, any>;
const draftBar = (lang: string) => (builtInLocales[lang] as Bag)?.preview?.draftBar as Bag;
const at = (lang: string, key: string) => draftBar(lang)?.[key] as string;

/** The four `es` values this file owns, at their post-objectui#3844 final bytes. */
const ES_FINAL = {
message:
'Vista previa del borrador — está viendo cambios sin publicar. Nada de lo que ve aquí está activo hasta que publique.',
messageClean:
'Vista previa del borrador — no hay cambios sin publicar; todo lo que ve aquí ya está activo.',
sampleDataBody:
'Está viendo la estructura de su aplicación. Publíquela para cargar registros de ejemplo y activarla.',
publishCta: 'Publicar para verlo activo',
} as const;

/** `en`'s current wording. If either moves, the `es` pins above must be revisited. */
const EN_CURRENT = {
message: 'Draft preview — you are seeing unpublished changes. Nothing here is live until you publish.',
messageClean: 'Draft preview — no unpublished changes; everything here is already live.',
} as const;

/**
* Exact substrings the pre-fix `es` value held, scoped to this key. Each names
* one sub-change of the fix, so a red points at which one came back:
* - the tú morphology (`estás viendo`, `publiques`);
* - `en producción` for "live", which the pack spells `activo` (see
* `publishCta` in this same object, `home.pendingDrafts.published`) and
* which additionally collides with the pack's *production environment*
* sense in `environment.entitlement.planLockedBody`;
* - `Vista previa de borrador`, the prefix that differed from
* `messageClean`'s `Vista previa del borrador` so the banner's two halves
* opened with different words where `en`'s both open "Draft preview — ".
*/
const RETIRED_SPELLINGS = [
'estás viendo',
'publiques',
'en producción',
'Vista previa de borrador',
] as const;

const EM_DASH = '—';
const BANNER_PREFIX = `Vista previa del borrador ${EM_DASH} `;

describe('preview.draftBar es register (objectui#3844)', () => {
it('the keys this file pins are present and non-empty', () => {
// Anti-empty-green: every assertion below reads one of these four values,
// and a renamed or dropped key would otherwise let the negative checks
// pass on `undefined`.
for (const key of Object.keys(ES_FINAL)) {
const value = at('es', key);
expect(typeof value, `es preview.draftBar.${key}`).toBe('string');
expect(value.trim().length, `es preview.draftBar.${key} is empty`).toBeGreaterThan(0);
}
for (const key of Object.keys(EN_CURRENT)) {
expect(typeof at('en', key), `en preview.draftBar.${key}`).toBe('string');
}
});

it.each(Object.entries(ES_FINAL))('es preview.draftBar.%s is exactly the ruled value', (key, expected) => {
// THE assertion. Byte-exact on purpose: register lives in individual
// morphemes (está vs estás, publique vs publiques), so a substring or
// regex check would let a partial re-edit through.
expect(at('es', key)).toBe(expected);
});

it('no retired spelling of the tú/en-producción wording is back', () => {
// Red before the fix on `message` for three of these four literals; kept
// as the named tripwire so a future re-edit that reintroduces one fails
// with the offending literal in the message rather than as an opaque
// byte diff.
for (const key of Object.keys(ES_FINAL)) {
const value = at('es', key);
for (const retired of RETIRED_SPELLINGS) {
expect(value.includes(retired), `es preview.draftBar.${key} reintroduced "${retired}"`).toBe(
false,
);
}
}
});

it('both halves of the banner open with the same words and the same em-dash', () => {
// The structural half of the fix, stated as its own claim: `en` opens both
// sentences "Draft preview — ", so `es` must open both identically too.
// `messageClean` used to separate with a colon, which made the banner
// change punctuation as well as person when the user published.
expect(at('es', 'message').startsWith(BANNER_PREFIX)).toBe(true);
expect(at('es', 'messageClean').startsWith(BANNER_PREFIX)).toBe(true);
expect(at('es', 'messageClean')).not.toContain('borrador:');
for (const key of ['message', 'messageClean']) {
expect(at('en', key).startsWith(`Draft preview ${EM_DASH} `), `en ${key} prefix`).toBe(true);
}
});

it('all four values spell "live" with the pack\'s own word', () => {
// "Same concept, same word" — the stem covers `activo` (message,
// messageClean, publishCta) and `activarla` (sampleDataBody). Paired with
// the `en producción` literal above: this side proves the replacement is
// present, that side proves the outlier is gone.
for (const key of Object.keys(ES_FINAL)) {
expect(at('es', key), `es preview.draftBar.${key} lost the pack's word for "live"`).toContain(
'activ',
);
}
});

it('en still says what the es values were translated against', () => {
// objectui#3650's gate only fires when an `en` value changes, and it asks
// the nine packs to follow — it cannot know this `es` ruling. This line
// goes red in the same PR that rewords either `en` sentence, which is what
// forces the usted rewrite above to be re-read then.
expect(at('en', 'message')).toBe(EN_CURRENT.message);
expect(at('en', 'messageClean')).toBe(EN_CURRENT.messageClean);
});

it('es serves neither en sentence verbatim', () => {
// The objectui#3582 failure mode (English pasted into a translation pack)
// applied to these keys. Green before this fix too — recorded as a guard
// against a future paste, not as evidence for objectui#3844.
const english = Object.values(EN_CURRENT) as string[];
expect(english).not.toContain(at('es', 'message'));
expect(english).not.toContain(at('es', 'messageClean'));
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3288,11 +3288,11 @@ const es = {
retry: 'Reintentar',
},
draftBar: {
messageClean: "Vista previa del borrador: no hay cambios sin publicar; todo lo que ve aquí ya está activo.",
messageClean: "Vista previa del borrador no hay cambios sin publicar; todo lo que ve aquí ya está activo.",
sampleDataTitle: "Los datos de ejemplo aparecen tras publicar",
sampleDataBody: "Está viendo la estructura de su aplicación. Publíquela para cargar registros de ejemplo y activarla.",
publishCta: "Publicar para verlo activo",
message: 'Vista previa de borrador — estás viendo cambios sin publicar. Nada está en producción hasta que publiques.',
message: 'Vista previa del borrador — está viendo cambios sin publicar. Nada de lo que ve aquí está activo hasta que publique.',
publish: 'Publicar',
publishing: 'Publicando…',
exit: 'Salir de la vista previa',
Expand Down
Loading