From 6ef0f249834831683e4dbc87111e6539192e8cec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:10:17 +0000 Subject: [PATCH] docs(types): the two i18n label vocabularies refuse each other, measured (objectui#9375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BaseSchema`'s vocabulary prose said the INLINE locale map (`label` / `description`) and the KEYED bundle reference (`ariaLabel`) "each accept the other's shape vacuously", and the `label` docblock restated it one property over. True when objectui#4580 Q2-B wrote it; the installed pin has closed it. Measured on `BaseSchema` itself, both crossings and both faces: a keyed ref in `label` / `description` and an inline map in `ariaLabel` are each refused at `tsc` (TS2322) and at `safeParse` on the zod mirror, with each shape accepted in its own slot as the control. The prose now says what a wrong slot costs — a wrong ANSWER, not a silent acceptance — and points at the cross-vocabulary block of `inline-locale-declared-face-9092.test.ts`, which re-derives it every run. Comment-only: no declaration, accept set or requiredness moves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L --- .../9375-base-i18n-vocabulary-docblock.md | 37 ++++++++++ packages/types/src/base.ts | 72 ++++++++++++------- 2 files changed, 85 insertions(+), 24 deletions(-) create mode 100644 .changeset/9375-base-i18n-vocabulary-docblock.md diff --git a/.changeset/9375-base-i18n-vocabulary-docblock.md b/.changeset/9375-base-i18n-vocabulary-docblock.md new file mode 100644 index 0000000000..b4c0a7811e --- /dev/null +++ b/.changeset/9375-base-i18n-vocabulary-docblock.md @@ -0,0 +1,37 @@ +--- +'@object-ui/types': patch +--- + +Correct the i18n vocabulary prose in `BaseSchema` — the two label shapes do NOT +accept each other, and an instrument in the tree re-derives that (objectui#9375). + +`base.ts` said of the INLINE locale map (`label` / `description`) and the KEYED +bundle reference (`ariaLabel`) that they "each accept the other's shape +vacuously", and the `label` docblock said the same thing one property over +("a keyed ref typed into this slot is accepted only *vacuously*"). That was true +when objectui#4580 Q2-B wrote it; the installed pin has since closed it, and the +sentence now reads as live in published `.d.ts` bytes while the tree says the +opposite. + +**Measured, both crossings and both faces, on `BaseSchema` itself.** A keyed ref +in `label` / `description` and an inline map in `ariaLabel` are each refused at +`tsc` (TS2322) and at `safeParse` on the zod mirror; each shape in its own slot +compiles and parses, which is the control that keeps the refusals from being a +slot that refuses every object. The refusal's mechanism is the pin's inline arm, +typed `key?: never; defaultValue?: never`, whose `INLINE_LOCALE_KEY` pattern +excludes both names. + +**What the docblock now says instead** is what a wrong slot actually costs — a +WRONG ANSWER, not a silent acceptance, paid by metadata that reaches a resolver +without passing either face: `resolveI18nLabel` hands a keyed ref back as its own +`key` string (so the key renders), and `resolveKeyedI18nLabel` answers `undefined` +for an inline map (so the aria-label renders empty). Both measured on the +installed pin. The live claim points at the cross-vocabulary block of +`packages/types/src/__tests__/inline-locale-declared-face-9092.test.ts`, which +re-derives it on every run, rather than restating the answer (AGENTS.md #9). + +⛔ No declaration, accept set or requiredness moves — the diff is comment-only, +and objectui#9092 keeps `BaseSchema` read-only. It is graded `patch` rather than +a no-release changeset because the published bytes move: the corrected prose ships +in `dist/base.d.ts`, where the old sentence was telling every reader — human and +AI — the opposite of what the type and the parser do. diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index 8bf38322b2..82e3861286 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -33,9 +33,11 @@ import type { ExpressionWire } from './expression.js'; * (`packages/react/src/utils/i18n.ts`, and the `t`-taking twin in * `packages/app-shell/src/utils/index.ts`). * - INLINE (`I18nLabel`, re-exported from `@objectstack/spec/ui`): - * `string | Record` — a locale MAP like - * `{ en: 'Owner' }` — resolved against a BCP-47 locale by the spec's own - * `resolveI18nLabel(label, locale)`. + * `string | Record`, with `key?: never; defaultValue?: + * never` on the map arm — a locale MAP like `{ en: 'Owner' }`, resolved + * against a BCP-47 locale by the spec's own `resolveI18nLabel(label, + * locale)`. Those two exclusions are what keep the KEYED shape out of an + * inline slot; {@link BaseSchema.ariaLabel} states both crossings. * * The shape below is the census of the three inline copies that existed before * this type was minted — `packages/react/src/utils/i18n.ts`, @@ -103,8 +105,9 @@ export interface BaseSchema { * ## Which vocabulary this is, and who resolves it * * This slot — and {@link BaseSchema.description} two lines down — carries the - * spec's INLINE form: `I18nLabel` = `string | Record`, a - * locale MAP like `{ en: 'Owner', 'zh-CN': '负责人' }`, resolved against a + * spec's INLINE form: `I18nLabel` = `string | Record` with + * `key` and `defaultValue` excluded from the map arm, a locale MAP like + * `{ en: 'Owner', 'zh-CN': '负责人' }`, resolved against a * BCP-47 locale by the spec's own `resolveI18nLabel(label, locale)` from * `@objectstack/spec/ui`. Its documented fallback order is exact match → * base/region (`zh-CN` ↔ `zh`) → last resort (any remaining entry), and it @@ -115,12 +118,16 @@ export interface BaseSchema { * vocabulary — the KEYED form {@link KeyedI18nLabel} (`{ key, defaultValue?, * params? }`), a reference INTO a translation bundle, resolved by * `resolveKeyedI18nLabel`. One interface now carries both, two properties - * apart, and they are structurally confusable: a keyed ref typed into this - * slot is accepted only *vacuously*, as a locale map whose "locales" are - * named `key` and `defaultValue`. That is objectui#4167's hazard, inherent to - * the spec's `I18nLabel` design and present on every spec surface using it; - * naming both shapes with cross-referenced docs is the accepted mitigation - * (#4580's revised Q1 ruling states this cost and accepts it). + * apart, and they stay confusable to a READER — but NOT to the compiler or + * the parser: a keyed ref written into this slot is REFUSED, because the + * installed pin types the inline arm with `key?: never; defaultValue?: + * never` and its `INLINE_LOCALE_KEY` pattern excludes both names. + * {@link BaseSchema.ariaLabel} carries the full statement — both crossings, + * both faces, and what a wrong slot actually costs. That confusability is + * objectui#4167's hazard, inherent to the spec's `I18nLabel` design and + * present on every spec surface using it; naming both shapes with + * cross-referenced docs is the accepted mitigation (#4580's revised Q1 + * ruling states this cost and accepts it). * * ## Resolution happens at READ time, not at the bridge * @@ -461,31 +468,48 @@ export interface BaseSchema { * * Accepts the KEYED i18n form as well as a plain string (objectui#4581), * because that is what the renderer resolves: - * `packages/react/src/SchemaRenderer.tsx:111` reads + * `packages/react/src/SchemaRenderer.tsx` reads * `aria['aria-label'] = resolveKeyedI18nLabel(schema.ariaLabel)`, and * `resolveKeyedI18nLabel` accepts `{ key, defaultValue?, params? }` — the * shape now named {@link KeyedI18nLabel}. * * NOT `I18nLabel`. The original #4581 text asked for `string | I18nLabel`, * and PR #4593 measured that spelling wrong in three ways before the ruling - * withdrew it (#4580 Q2-B): `I18nLabel` is the spec's INLINE LOCALE MAP - * (`string | Record`), so the shipped keyed fixture was - * accepted only *vacuously* — as a locale map whose "locales" are named `key` - * and `defaultValue`; the same label carrying `params` was REJECTED + * withdrew it (#4580 Q2-B). ⚠️ Read those three as HISTORY, against the + * `I18nLabel` of that release — `string | Record`, a map + * with no name excluded from it: the shipped keyed fixture was accepted + * *vacuously*, as a locale map whose "locales" are named `key` and + * `defaultValue`; the same label carrying `params` was REJECTED * (`Type '{ name: string; }' is not assignable to type 'string'`); and a * genuine `{ en: 'Owner' }` type-checked while `resolveKeyedI18nLabel` - * returns `undefined` for it, rendering an EMPTY aria-label. The two - * vocabularies are structurally confusable — objectui#4167's exact hazard. + * returns `undefined` for it, rendering an EMPTY aria-label. The pin has + * since closed the vacuous half — see below. What survives is that the two + * vocabularies are structurally confusable to a READER — objectui#4167's + * exact hazard. * - * ⚠️ That hazard is now LIVE ON THIS INTERFACE, not just adjacent to it: - * since #4580's revised Q1 ruling, {@link BaseSchema.label} and + * ⚠️ That hazard is LIVE ON THIS INTERFACE, not just adjacent to it: since + * #4580's revised Q1 ruling, {@link BaseSchema.label} and * {@link BaseSchema.description} declare the spec's INLINE map (`I18nLabel`, * resolved by `resolveI18nLabel(label, locale)`), while this slot declares * the KEYED ref (resolved by `resolveKeyedI18nLabel`). Two properties apart, - * both spelled `string | {object}`, and each accepts the other's shape - * vacuously. Check which resolver owns a slot before writing an object into - * it; the ruling accepted this cost with exactly this naming + cross- - * referencing as the mitigation. + * both spelled `string | {object}` — but NEITHER SLOT ADMITS THE OTHER'S + * SHAPE. The installed pin types the inline arm with `key?: never; + * defaultValue?: never` and its `INLINE_LOCALE_KEY` pattern excludes both + * names, so a keyed ref written into `label` / `description`, and an inline + * map written into this slot, are each REFUSED — at `tsc` on the declaration + * and at `safeParse` on the zod mirror. The cross-vocabulary block of + * `__tests__/inline-locale-declared-face-9092.test.ts` re-derives that on + * every run, in both directions and with the accepting control beside each + * refusal; read it rather than this sentence. + * + * ⚠️ So what a wrong slot costs is a WRONG ANSWER rather than a silent + * acceptance, and it is paid by metadata that reaches a resolver without + * having passed either face — the normal case for server-driven JSON. A + * keyed ref handed to `resolveI18nLabel` comes back as its own `key` string, + * so the KEY is what renders; an inline map handed to `resolveKeyedI18nLabel` + * comes back `undefined`, so the aria-label renders EMPTY. Check which + * resolver owns a slot before writing an object into it; the ruling accepted + * this cost with exactly this naming + cross-referencing as the mitigation. * * @example "Close dialog" * @example { key: 'dialog.close', defaultValue: 'Close dialog' }