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
37 changes: 37 additions & 0 deletions .changeset/9375-base-i18n-vocabulary-docblock.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 48 additions & 24 deletions packages/types/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` — a locale MAP like
* `{ en: 'Owner' }` — resolved against a BCP-47 locale by the spec's own
* `resolveI18nLabel(label, locale)`.
* `string | Record<string, string>`, 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`,
Expand Down Expand Up @@ -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<string, string>`, a
* locale MAP like `{ en: 'Owner', 'zh-CN': '负责人' }`, resolved against a
* spec's INLINE form: `I18nLabel` = `string | Record<string, string>` 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
Expand All @@ -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
*
Expand Down Expand Up @@ -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<string, string>`), 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<string, string>`, 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' }
Expand Down
Loading