diff --git a/.changeset/9092-inline-locale-declared-face.md b/.changeset/9092-inline-locale-declared-face.md new file mode 100644 index 0000000000..de7d16457f --- /dev/null +++ b/.changeset/9092-inline-locale-declared-face.md @@ -0,0 +1,67 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-grid': patch +--- + +Group A of objectui#7759: three declarations restated a label key as a plain +`string` and now state the spec's inline locale map (objectui#9092). + +objectui#4580's revised Q1 ruling (option A) widened the label keys to +`string | I18nLabel` — a plain string **or** an inline per-locale map like +`{ en: 'Accounts', 'fr-FR': 'Comptes' }`, resolved by the spec's own +`resolveI18nLabel(label, locale)`. `BaseSchema` obeyed it on both faces. These +three restated the key on top of it: + +- `AppComponentSchema.label` (`app.ts`) +- `ObjectGridSchema.label` and `.description` (`objectql.ts`) +- `PageNodeSchema.aria.ariaLabel` (`layout.ts`) + +A restatement on an interface that extends `BaseSchema` is a **narrowing +override**, so each of these refused the map its own zod mirror accepted. The +mirrors needed no change: the first three inherit the zod `BaseSchema`'s +`I18nLabelSchema`, and the page node receives the spec's `AriaPropsSchema` by +reference. The defect was therefore declaration-only, and it sat on the side a +forward mirror-vs-declaration comparison reads as clean — an author following the +published ruling was refused by `tsc` while `safeParse` said yes. + +`ObjectViewSchema.table` is `Partial>` and picks up the +same repair mechanically. + +**One runtime behaviour changes, at three sites.** `@object-ui/plugin-grid`'s +`ObjectGrid` put `schema.label` straight into three string positions — the +data-table caption, the export filename, and the record-detail overlay heading. +Restoring the declaration turned the first two into named compiler errors, which +is the audit the widening exists to produce: a map-valued label reached the +caption as an object and the export filename as `[object Object]`. + +The third is the one a compiler cannot report, and it is worth knowing why. The +overlay heading goes through `t(key, options)`, whose options are +`Record`, so the widening slips through an untyped sink and +nothing is flagged — while an unresolved map interpolates as the user-visible +heading `[object Object] Detail`, on the i18next path and on the provider-less +fallback alike. After a widening, `tsc` names the typed readers; the untyped +sinks (`t()` options, `String(…)`, template literals, `JSON.stringify`) have to +be found by hand. + +All three now resolve through the spec's `resolveI18nLabel` against +`useDisplayLocale()`, matching the read sites that already did. Behaviour on the +string arm is unchanged, byte for byte. On the heading, a label that resolves to +nothing — an entry-less map, or an empty entry — falls through to the +`objectName` branch exactly as a missing label always did; testing the raw +`schema.label` could not do that, because every object is truthy. + +⚠️ Not touched, deliberately: the **flat** `BaseSchema.ariaLabel`. It carries the +other vocabulary — objectui's keyed `{ key, defaultValue?, params? }` reference, +resolved by `resolveKeyedI18nLabel` — and objectui#4580 Q2-B withdrew the +`I18nLabel` spelling there as measured-wrong. The nested `aria.ariaLabel` widened +here is the inline form, which is what `@objectstack/spec`'s `AriaPropsSchema` +declares and what objectui#5134 made `ListView` resolve. The two object shapes are +structurally confusable to a reader, but **neither vocabulary admits the other**: +`InlineLocaleMapSchema` types its map with `key?: never; defaultValue?: never`, +and its `INLINE_LOCALE_KEY` pattern excludes both names, so writing one into the +other's slot is refused at `tsc` and at parse alike. (An earlier draft of this +note said each shape accepted the other vacuously — that was true when +objectui#4580 Q2-B wrote it, and the protocol has since closed it.) What a wrong +slot costs you is a wrong **answer** rather than a silent acceptance: +`resolveI18nLabel` hands a keyed reference back as its own `key` string. So still +check which resolver owns a slot before writing an object into it. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index f3a82145b7..d6bab0c515 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -35,6 +35,17 @@ import { createSafeTranslation } from '@object-ui/i18n'; import { resolveGridCellRendering, gridCellRendererForFixedKey, BADGE_PREFIX_RENDERER_KEY } from './cellRendererResolution'; import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue } from '@object-ui/fields'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; +// Two resolvers, two vocabularies — the repo spells the distinction into the +// NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own +// `resolveI18nLabel`: it resolves the INLINE per-locale map +// (`{ en: …, 'fr-FR': … }`) that `I18nLabel` carries. It does NOT accept +// objectui's keyed `{ key, defaultValue, params }` ref — that vocabulary lives +// on the FLAT `schema.ariaLabel` and is resolved by `SchemaRenderer` instead. +// Needed here since objectui#9092 restored `ObjectGridSchema.label` to the +// `string | I18nLabel` form `BaseSchema` has carried since objectui#4580: the +// two reads below put the label in STRING positions, so a map-valued label used +// to reach them as an object and the compiler could not say so. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { stateMachineNextValues, isFieldInlineEditable } from './inline-edit-options'; import { Badge, Button, NavigationOverlay, EmptyValue, @@ -3205,7 +3216,7 @@ export const ObjectGrid: React.FC = ({ prefix: exportConfig?.fileNamePrefix, label: objectSchema?.label, objectName: objectName || schema.objectName, - viewLabel: schema.label || schema.title, + viewLabel: resolveInlineI18nLabel(schema.label, displayLocale) || schema.title, }); // Server-streamed path: csv / xlsx / json via dataSource.exportDownload. @@ -4288,7 +4299,7 @@ export const ObjectGrid: React.FC = ({ const dataTableSchema: ObjectGridDataTableSchema = { type: 'data-table', - caption: schema.label || schema.title, + caption: resolveInlineI18nLabel(schema.label, displayLocale) || schema.title, columns: orderedColumns, data, pagination: paginationEnabled, @@ -4564,8 +4575,25 @@ export const ObjectGrid: React.FC = ({ // `Contacts Detail` / `Record Detail`), including with no `I18nProvider` // mounted — `createSafeTranslation`'s fallback interpolates `{{label}}` from // `GRID_DEFAULT_TRANSLATIONS`. - const detailTitle = schema.label - ? t('detail.recordDetailWithLabel', { label: schema.label }) + // + // ⚠️ The label is RESOLVED before it reaches `t()` (objectui#9092). This is an + // UNTYPED sink: `t`'s options are `Record`, so when + // `ObjectGridSchema.label` was restored to `string | I18nLabel` the compiler + // named the two `string`-typed reads above and said nothing about this one. + // Unresolved, an inline locale map interpolates as `[object Object]` on BOTH + // paths — i18next substitutes the raw value, and the provider-less + // `interpolateFallback` runs it through `String(v)` — and this value IS the + // overlay's visible heading (`NavigationOverlay title=`, below), so the + // failure is user-facing rather than diagnostic. + // + // The fallthrough is deliberate: `resolveI18nLabel` answers `undefined` for an + // entry-less map and `''` for an empty entry, and both are falsy, so a label + // that resolves to nothing lands on the `objectName` branch exactly as a + // missing label always did. Testing `schema.label` itself could not do that — + // every object is truthy, so an entry-less map used to take the label branch. + const resolvedDetailLabel = resolveInlineI18nLabel(schema.label, displayLocale); + const detailTitle = resolvedDetailLabel + ? t('detail.recordDetailWithLabel', { label: resolvedDetailLabel }) : schema.objectName ? t('detail.recordDetailWithLabel', { label: schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1), diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleInlineLocale-9092.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleInlineLocale-9092.test.tsx new file mode 100644 index 0000000000..7f3e3b68cf --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleInlineLocale-9092.test.tsx @@ -0,0 +1,156 @@ +/** + * 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. + */ + +/** + * `ObjectGrid`'s record-detail overlay heading resolves an INLINE locale map + * label instead of stringifying it — objectui#9092. + * + * ── Why this site needed its own pin ────────────────────────────────────── + * objectui#9092 restored `ObjectGridSchema.label` to `string | I18nLabel`, the + * form `BaseSchema` has carried since objectui#4580. The other two reads in + * `ObjectGrid.tsx` that put the label in a string position were found because + * `tsc` NAMED them: the data-table `caption` and the export `viewLabel` are + * both `string`-typed sinks, so the widening surfaced them as compile errors. + * + * This one is different, and that is the transferable part: `detailTitle` hands + * the label to `createSafeTranslation`'s `t(key, options)`, whose options + * parameter is `Record` (`i18n/src/useSafeTranslation.ts`). An + * `unknown`-typed sink SWALLOWS the diagnostic, so an inventory built from + * compiler errors cannot reach this site — it has to be found by hand. After a + * widening, `tsc` finds the typed readers; `t()` options, `String(…)`, template + * literals and `JSON.stringify` do not report. + * + * ── What goes wrong when it is missed ───────────────────────────────────── + * `detailTitle` is handed to `NavigationOverlay`'s `title` prop (three call + * sites in `ObjectGrid.tsx`), which means it IS the visible heading of the + * record-detail drawer/modal/split/popover — not a diagnostic, not a log line. + * An unresolved map interpolates as `[object Object]`, on BOTH i18n paths: + * i18next substitutes the raw value into `'{{label}} Detail'`, and the + * provider-less fallback runs it through `String(v)` + * (`i18n/src/fallbackInterpolation.ts`). So the user-visible heading reads + * `[object Object] Detail`. + * + * ── Direction of these assertions (red-first) ───────────────────────────── + * The map cases were RED before the fix (`[object Object] Detail`) and are + * GREEN after (`Accounts Detail` / `联系人详情`). The STRING cases were GREEN + * before AND after: they are the control that must not move — resolving a plain + * string through `resolveI18nLabel` returns it unchanged, so English (and every + * other) output on the string arm is byte-identical to what objectui#3426 left. + * + * The provider-LESS half of the same fix is asserted in + * `ObjectGrid.overlayTitleNoProviderFallback.test.tsx`. It cannot live in this + * file: `createI18n` registers its instance as react-i18next's module-global + * default and that registration survives `cleanup()`, so a "no provider" render + * here would silently resolve against whichever locale a previous test mounted. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; +import type { ObjectGridSchema } from '@object-ui/types'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +/** + * Typed against the DECLARED face on purpose. `tsc -p tsconfig.test.json` reads + * this file, so re-narrowing `ObjectGridSchema.label` back to a plain `string` + * fails the type half of this pin as well as the runtime half. + */ +type GridLabel = NonNullable; + +const MAP_LABEL: GridLabel = { en: 'Accounts', zh: '联系人' }; +const STRING_LABEL: GridLabel = 'Accounts'; +/** An entry-less map resolves to `undefined` — the `objectName` branch must take over. */ +const ENTRYLESS_MAP: GridLabel = {}; +/** An empty entry resolves to `''` — falsy, so the same fallthrough applies. */ +const EMPTY_ENTRY_MAP: GridLabel = { en: '' }; + +const rows = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, +]; + +function renderGridIn(language: string, schemaExtra: Record) { + return render( + + + + + , + ); +} + +/** Open the detail overlay the way a user does: click a row. */ +async function openOverlay() { + const cell = await screen.findByText('Alice'); + fireEvent.click(cell); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); +} + +afterEach(() => cleanup()); + +describe('ObjectGrid record-detail overlay heading — inline locale map label (objectui#9092)', () => { + it('resolves the map against the display locale instead of stringifying it', async () => { + renderGridIn('en', { label: MAP_LABEL }); + await openOverlay(); + + expect(screen.getByText('Accounts Detail')).toBeInTheDocument(); + // The defect this pin exists to catch, spelled out rather than implied. + expect(screen.queryByText('[object Object] Detail')).toBeNull(); + }); + + it('CONTROL — a plain-string label renders the same bytes it always did', async () => { + renderGridIn('en', { label: STRING_LABEL }); + await openOverlay(); + + expect(screen.getByText('Accounts Detail')).toBeInTheDocument(); + expect(screen.queryByText('[object Object] Detail')).toBeNull(); + }); + + it('reads the session locale, not a hard-coded `en` arm', async () => { + // Proves the resolver is wired to `useDisplayLocale()`: the same map picks + // its zh entry, and the zh bundle's own word order (`{{label}}详情`) applies. + renderGridIn('zh', { label: MAP_LABEL }); + await openOverlay(); + + expect(screen.getByText('联系人详情')).toBeInTheDocument(); + expect(screen.queryByText('[object Object]详情')).toBeNull(); + }); + + it('falls through to the capitalized objectName when the map resolves to nothing', async () => { + // An entry-less map is the one input `resolveI18nLabel` answers `undefined` + // for. The old `schema.label ? …` test was TRUTHY for it (any object is), + // so the heading would have interpolated an empty-ish object; the branch + // must land on `objectName` exactly as a missing label always did. + renderGridIn('en', { label: ENTRYLESS_MAP }); + await openOverlay(); + + expect(screen.getByText('Contacts Detail')).toBeInTheDocument(); + }); + + it('falls through to the capitalized objectName when the entry is empty', async () => { + renderGridIn('en', { label: EMPTY_ENTRY_MAP }); + await openOverlay(); + + expect(screen.getByText('Contacts Detail')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleNoProviderFallback.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleNoProviderFallback.test.tsx index 5efaf356ab..20b04c0e9a 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleNoProviderFallback.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.overlayTitleNoProviderFallback.test.tsx @@ -30,6 +30,16 @@ * it red by rendering the raw key `detail.recordDetailWithLabel`, which is * precisely the regression it exists to catch. * + * ── The second describe block has the opposite direction (objectui#9092) ── + * The `inline locale map label` block below was RED before objectui#9092's grid + * fix and is GREEN after. It belongs in THIS file rather than in a third one + * because the defect is path-specific: a map label reaches `[object Object]` + * through TWO different interpolators, i18next's and this file's provider-less + * `interpolateFallback` (`i18n/src/fallbackInterpolation.ts`, `String(v)`), and + * a pin that exercised only the provider path would leave the `String(v)` arm + * unmeasured. The file-splitting rule below is what makes this arm reachable at + * all, so it is a reason to keep that rule, not an exception to it. + * * ── Why this is its own FILE, not a describe block ──────────────────────── * `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next` * registers that instance as **react-i18next's module-global default**. The @@ -49,10 +59,14 @@ import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/re import '@testing-library/jest-dom'; import { registerAllFields } from '@object-ui/fields'; import { ActionProvider } from '@object-ui/react'; +import type { ObjectGridSchema } from '@object-ui/types'; import { ObjectGrid } from '../ObjectGrid'; registerAllFields(); +/** Typed against the DECLARED face — see the sibling file's note on why. */ +type GridLabel = NonNullable; + const rows = [ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }, @@ -106,3 +120,34 @@ describe('ObjectGrid overlay heading — English fallback with no provider (obje expect(screen.queryByText('detail.recordDetail')).toBeNull(); }); }); + +describe('ObjectGrid overlay heading — inline locale map label, no provider (objectui#9092)', () => { + it('resolves the map before interpolation, never `String(v)`s it', async () => { + const label: GridLabel = { en: 'Accounts', zh: '联系人' }; + renderGrid({ objectName: 'contacts', label }); + await openOverlay(); + + // `interpolateFallback` runs each value through `String(v)`. Unresolved, + // this heading reads `[object Object] Detail` — user-visible chrome. + expect(screen.getByText('Accounts Detail')).toBeInTheDocument(); + expect(screen.queryByText('[object Object] Detail')).toBeNull(); + expect(screen.queryByText('detail.recordDetailWithLabel')).toBeNull(); + }); + + it('CONTROL — a plain-string label renders the same bytes it always did', async () => { + const label: GridLabel = 'Accounts'; + renderGrid({ objectName: 'contacts', label }); + await openOverlay(); + + expect(screen.getByText('Accounts Detail')).toBeInTheDocument(); + expect(screen.queryByText('[object Object] Detail')).toBeNull(); + }); + + it('falls through to the capitalized objectName when the map resolves to nothing', async () => { + const label: GridLabel = {}; + renderGrid({ objectName: 'contacts', label }); + await openOverlay(); + + expect(screen.getByText('Contacts Detail')).toBeInTheDocument(); + }); +}); diff --git a/packages/types/src/__tests__/inline-locale-declared-face-9092.test.ts b/packages/types/src/__tests__/inline-locale-declared-face-9092.test.ts new file mode 100644 index 0000000000..3010b32830 --- /dev/null +++ b/packages/types/src/__tests__/inline-locale-declared-face-9092.test.ts @@ -0,0 +1,198 @@ +/** + * objectui#9092 — the DECLARED face of group A accepts the spec's inline locale + * map, and the three pairs no longer restate the key as a plain `string`. + * + * ## What this pins, and why the pin has to be two-sided + * + * objectui#4580's revised Q1 ruling (option A) widened the label keys to the + * spec's INLINE locale map, and `BaseSchema` carries it on both faces. Three + * pairs restated the key as a plain `string`, which is a NARROWING override of + * the inherited member — so their zod mirrors accepted an authored locale map + * and `tsc` refused it. The narrowing sat on the DECLARED side, where a forward + * mirror-vs-declaration comparison reads the pair as clean; `zod-mirror-parity`'s + * `WiderThanDeclared` ledger is the instrument that saw it, and its entries for + * these keys retired with this card. + * + * A one-sided pin would go green for the wrong reason. `BaseSchema` carries + * `[key: string]: any`, so a key REMOVED from a declaration type-checks exactly + * like a key WIDENED — the assignment below would keep compiling if someone + * deleted the member outright. So each positive case is paired with a + * `@ts-expect-error` negative on a key that is genuinely a plain `string`: a + * `@ts-expect-error` whose error stops occurring is itself a compile error, so + * that half fails loudly if the narrow face ever becomes uncheckable. + * + * ⚠️ The vocabulary split this card does NOT touch. The FLAT + * `BaseSchema.ariaLabel` is the KEYED form (`{ key, defaultValue?, params? }`, + * resolved by `resolveKeyedI18nLabel`), and objectui#4580 Q2-B withdrew the + * `I18nLabel` spelling there as measured-wrong. The NESTED `aria.ariaLabel` + * asserted below is the INLINE form — the spec's own `AriaPropsSchema` + * spelling, and the one objectui#5134 made `ListView` resolve with + * `resolveI18nLabel`. + * + * ⚠️ What this file asserts about the FLAT key, exactly. Only that an inline + * map is REFUSED there. The four widening cases are the three NESTED/INLINE + * members plus the `BaseSchema` reference face, and that reference case asserts + * `label` and `description` — `BaseSchema.ariaLabel` has no positive assertion + * anywhere in this file. An earlier draft of this header said "both are + * asserted here, each against its own vocabulary"; it overstated what is here. + * + * ⚠️ And neither vocabulary admits the other. An earlier draft said the two + * shapes "each accept the other vacuously" — quoted from objectui#4580 Q2-B, + * true when that was written and measured FALSE on the installed pin. + * `InlineLocaleMapSchema` types its map with `key?: never; defaultValue?: never` + * and its own `INLINE_LOCALE_KEY` pattern excludes both names, so the cross + * assignment is refused at `tsc` AND at parse. The last describe block below is + * the instrument that re-derives that on every run — read it rather than this + * sentence. What a wrong slot still costs is a wrong ANSWER, not a silent + * acceptance: `resolveI18nLabel` hands a keyed ref back as its own `key` string. + * So the advice is unchanged — check which resolver owns a slot before writing + * an object into it. + * + * ⚠️ Two halves, two runners. Every `@ts-expect-error` and every typed + * assignment below is read ONLY by `tsc -p packages/types/tsconfig.test.json` + * (the package `type-check` script, and CI's Type Check job). `vitest` strips + * types, so a vitest-only run is a FALSE GREEN on that half of this file. + */ +import { describe, it, expect } from 'vitest'; +import type { I18nLabel } from '@objectstack/spec/ui'; + +import type { AppComponentSchema } from '../app'; +import type { ObjectGridSchema } from '../objectql'; +import type { PageNodeSchema } from '../layout'; +import type { BaseSchema } from '../base'; + +import { AppComponentSchema as AppComponentMirror } from '../zod/app.zod.js'; +import { ObjectGridSchema as ObjectGridMirror } from '../zod/objectql.zod.js'; +import { PageNodeSchema as PageNodeMirror } from '../zod/layout.zod.js'; + +/** The spec's inline locale map, as an author writes it. */ +const LOCALE_MAP: I18nLabel = { en: 'Accounts', 'fr-FR': 'Comptes' }; + +describe('objectui#9092 — declared face admits the inline locale map', () => { + it('AppComponentSchema.label takes the map, and a plain-string sibling still refuses one', () => { + const widened: AppComponentSchema = { type: 'app', label: LOCALE_MAP }; + + // CONTROL — `icon` is a genuinely plain `string` on the same interface and + // is NOT in this card's scope. If this directive ever reports "unused", the + // widening has leaked past the keys the ruling names. + // @ts-expect-error `icon` is `string`; the map is refused here and must stay refused. + const control: AppComponentSchema = { type: 'app', icon: LOCALE_MAP }; + + expect(widened.label).toEqual(LOCALE_MAP); + expect(control.icon).toEqual(LOCALE_MAP); + }); + + it('ObjectGridSchema.label and .description take the map; `objectName` still refuses one', () => { + const label: ObjectGridSchema = { type: 'object-grid', objectName: 'accounts', label: LOCALE_MAP }; + const description: ObjectGridSchema = { type: 'object-grid', objectName: 'accounts', description: LOCALE_MAP }; + + // CONTROL — `objectName` is a required plain `string` on the same interface. + // @ts-expect-error `objectName` is `string`; the map is refused here and must stay refused. + const control: ObjectGridSchema = { type: 'object-grid', objectName: LOCALE_MAP }; + + expect(label.label).toEqual(LOCALE_MAP); + expect(description.description).toEqual(LOCALE_MAP); + expect(control.objectName).toEqual(LOCALE_MAP); + }); + + it('PageNodeSchema.aria.ariaLabel takes the map; the sibling `ariaDescribedBy` still refuses one', () => { + const widened: PageNodeSchema = { type: 'page', aria: { ariaLabel: LOCALE_MAP } }; + + // CONTROL — `ariaDescribedBy` is an ID reference, `string` in the spec's own + // `AriaPropsSchema`. It is the sibling KEY on the SAME object, so it also + // proves the widening landed on one member rather than on the whole slot. + // @ts-expect-error `ariaDescribedBy` is `string`; the map is refused here and must stay refused. + const control: PageNodeSchema = { type: 'page', aria: { ariaDescribedBy: LOCALE_MAP } }; + + expect(widened.aria?.ariaLabel).toEqual(LOCALE_MAP); + expect(control.aria?.ariaDescribedBy).toEqual(LOCALE_MAP); + }); + + it('the reference face did not move: BaseSchema.label already took the map before this card', () => { + // objectui#4580's revised Q1 ruling landed here, and this card is forbidden + // from touching `base.ts`. This case is the control that holds still. + const reference: BaseSchema = { type: 'text', label: LOCALE_MAP, description: LOCALE_MAP }; + expect(reference.label).toEqual(LOCALE_MAP); + expect(reference.description).toEqual(LOCALE_MAP); + }); +}); + +describe('objectui#9092 — the zod mirrors already accepted what tsc refused', () => { + // The card's item 4: a mirror that did NOT accept the map would make the pair a + // defect in the OPPOSITE direction, to be reported rather than silently fixed. + // Every one of them accepts it, so the repair is declaration-only. + it('the mirror accepts the map on every key this card widened', () => { + expect(AppComponentMirror.safeParse({ type: 'app', label: LOCALE_MAP }).success).toBe(true); + expect(ObjectGridMirror.safeParse({ type: 'object-grid', objectName: 'accounts', label: LOCALE_MAP }).success).toBe(true); + expect(ObjectGridMirror.safeParse({ type: 'object-grid', objectName: 'accounts', description: LOCALE_MAP }).success).toBe(true); + expect(PageNodeMirror.safeParse({ type: 'page', name: 'home', aria: { ariaLabel: LOCALE_MAP } }).success).toBe(true); + }); + + it('CONTROL — the same mirrors still refuse a value no arm admits', () => { + // Without this, the assertions above pass on a mirror that accepts anything. + expect(AppComponentMirror.safeParse({ type: 'app', label: 42 }).success).toBe(false); + expect(ObjectGridMirror.safeParse({ type: 'object-grid', objectName: 'accounts', label: 42 }).success).toBe(false); + expect(ObjectGridMirror.safeParse({ type: 'object-grid', objectName: 'accounts', description: 42 }).success).toBe(false); + expect(PageNodeMirror.safeParse({ type: 'page', name: 'home', aria: { ariaLabel: 42 } }).success).toBe(false); + }); +}); + +/** + * objectui#9092 — the two vocabularies do not admit each other, on either face. + * + * This block exists because the header used to ASSERT that in prose, in words + * ("each accepts the other vacuously") that an instrument now refutes. AGENTS.md + * #9: point at the thing that re-derives the claim instead of writing the answer + * down. So the claim lives here, where every run re-derives it, and the header + * points at this block. + * + * ⚠️ The `tsc` half and the `safeParse` half are read by DIFFERENT runners — + * see the header. Both are needed: the type face and the parse face are + * separate contracts, and this pair is precisely where they were once believed + * to disagree. + */ +describe('objectui#9092 — the INLINE and KEYED vocabularies refuse each other', () => { + /** objectui's KEYED reference — legal on the FLAT `BaseSchema.ariaLabel`, nowhere below. */ + const KEYED_REF = { key: 'grid.accounts', defaultValue: 'Accounts' }; + + it('tsc: a keyed ref is refused by every member this card widened', () => { + // @ts-expect-error `label` is `string | I18nLabel`; `key`/`defaultValue` are `never` on the map arm. + const grid: ObjectGridSchema = { type: 'object-grid', objectName: 'accounts', label: KEYED_REF }; + // @ts-expect-error same arm, same refusal. + const app: AppComponentSchema = { type: 'app', label: KEYED_REF }; + // @ts-expect-error the NESTED aria slot is the inline vocabulary too. + const page: PageNodeSchema = { type: 'page', aria: { ariaLabel: KEYED_REF } }; + + expect(grid.label).toEqual(KEYED_REF); + expect(app.label).toEqual(KEYED_REF); + expect(page.aria?.ariaLabel).toEqual(KEYED_REF); + }); + + it('tsc: an inline map is refused by the FLAT `BaseSchema.ariaLabel`, which the keyed ref owns', () => { + // @ts-expect-error `ariaLabel` is `string | KeyedI18nLabel`; an inline map is excess there. + const refused: BaseSchema = { type: 'text', ariaLabel: LOCALE_MAP }; + + // CONTROL — the keyed ref IS legal here. Without this the negative above + // would also pass on a slot that refused every object, which would say + // nothing about the two vocabularies. + const accepted: BaseSchema = { type: 'text', ariaLabel: KEYED_REF }; + + expect(refused.ariaLabel).toEqual(LOCALE_MAP); + expect(accepted.ariaLabel).toEqual(KEYED_REF); + }); + + it('parse: the mirrors refuse a keyed ref on the same keys that take the map', () => { + expect(AppComponentMirror.safeParse({ type: 'app', label: KEYED_REF }).success).toBe(false); + expect(ObjectGridMirror.safeParse({ type: 'object-grid', objectName: 'accounts', label: KEYED_REF }).success).toBe(false); + expect(ObjectGridMirror.safeParse({ type: 'object-grid', objectName: 'accounts', description: KEYED_REF }).success).toBe(false); + expect(PageNodeMirror.safeParse({ type: 'page', name: 'home', aria: { ariaLabel: KEYED_REF } }).success).toBe(false); + }); + + it('parse CONTROL — the keyed ref is a legal value on the FLAT `ariaLabel`', () => { + // The refusals above are about the SLOT, not about the value: the same + // object parses green one property away, on the key that owns it. + expect(ObjectGridMirror.safeParse({ type: 'object-grid', objectName: 'accounts', ariaLabel: KEYED_REF }).success).toBe(true); + // …and the inline map is refused THERE, the other direction of the same split. + expect(ObjectGridMirror.safeParse({ type: 'object-grid', objectName: 'accounts', ariaLabel: LOCALE_MAP }).success).toBe(false); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 877d0ee2d2..30ad6fd282 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -257,8 +257,8 @@ * spelled "six" rots exactly as fast as one spelled `6`, it is just harder to * point a regex at. ⛔ Do not spell a live figure out again, and ⛔ do not * restate one without checking that the pin's spelling still reaches it. - * - **22 entries** in `WiderThanDeclared`, **35 keys** across them, and **45 arms** - * under those keys — split **6** SCHEMA-NODE, **29** CONCRETE, **0** MIXED, **10** unions. + * - **20 entries** in `WiderThanDeclared`, **30 keys** across them, and **37 arms** + * under those keys — split **5** SCHEMA-NODE, **25** CONCRETE, **0** MIXED, **7** unions. * ⭐ objectui#8517 taught the operator to tell an OPEN record — `z.record(z.string(), V)` * — from a partial record over a finite key union, and NOT ONE figure on this line moved * with it. ⛔ Do not read that as the clause measuring nothing. It was built on two live @@ -2454,17 +2454,18 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne */ interface WiderThanDeclared { /** - * CONCRETE `label` + SCHEMA-NODE `areas`. (`actions` left under objectui#7760: its - * element is a schema-node slot, and once `SchemaNodeSchema` carried its input face - * the key measured clean.) - - * `label` is the INLINE-LOCALE class: `BaseSchema`'s mirror spells the key - * `I18nLabelSchema` — a plain string OR an inline locale map — while this - * declaration restates `label?: string` and so refuses the map its own mirror - * accepts. The narrowing lives on the DECLARED side, which is why the forward - * comparison reads the pair as clean. + * SCHEMA-NODE `areas`. (`actions` left under objectui#7760: its element is a + * schema-node slot, and once `SchemaNodeSchema` carried its input face the key + * measured clean.) + * + * `label` LEFT under objectui#9092, the INLINE-LOCALE class: the mirror spelled + * the key `I18nLabelSchema` — a plain string OR an inline locale map — while the + * declaration restated `label?: string` and refused the map its own mirror + * accepted. The declaration now states `string | I18nLabel`, the form + * objectui#4580's revised Q1 ruling (option A) put on `BaseSchema.label`, so the + * pair measures clean and this entry would be STALE if it stayed. */ - 'app.zod.ts#AppComponentSchema': 'label' | 'areas'; + 'app.zod.ts#AppComponentSchema': 'areas'; /** * CONCRETE, and DISJOINT rather than strictly wider — the pair also carries a * `KnownDrift` entry for the same key, one of the measured cases where each face @@ -2539,12 +2540,21 @@ interface WiderThanDeclared { */ 'layout.zod.ts#ContainerSchema': 'maxWidth'; /** - * MIXED: `aria` carries the inline-locale widening one level down; `slots` is - * SCHEMA-NODE. (`regions` left under objectui#7760 — its element's content is a - * schema-node list, so its reading WAS the annotation. `slots` did not move, so the - * unconstrained position on ITS path is not one of the ten consts that card filled.) + * SCHEMA-NODE `slots`. (`regions` left under objectui#7760 — its element's content + * is a schema-node list, so its reading WAS the annotation. `slots` did not move, so + * the unconstrained position on ITS path is not one of the ten consts that card + * filled.) + * + * `aria` LEFT under objectui#9092: it carried the inline-locale widening one level + * down, on `ariaLabel`. The mirror receives the spec's own `AriaPropsSchema` BY + * REFERENCE through `SpecPageFields` (the spec's `PageSchema` declares `aria` at its + * top level), and that schema spells `ariaLabel` as `z.union([z.string(), + * InlineLocaleMapSchema])`; the declaration restated the string arm alone. It now + * states `string | I18nLabel` — the NESTED slot's vocabulary, not the FLAT + * `BaseSchema.ariaLabel`'s KEYED one, which objectui#4580 Q2-B deliberately left + * narrow. */ - 'layout.zod.ts#PageNodeSchema': 'aria' | 'slots'; + 'layout.zod.ts#PageNodeSchema': 'slots'; /** * CONCRETE. `variant` is DISJOINT — one variant spelling on each side the other * refuses; also in `KnownDrift`. `logo` ENTERED under objectui#7760: the mirror is @@ -2557,14 +2567,6 @@ interface WiderThanDeclared { * deep. */ 'navigation.zod.ts#HeaderBarSchema': 'logo' | 'variant'; - /** CONCRETE, INLINE-LOCALE: both keys are `I18nLabelSchema` on the mirror and restated as plain strings on this declaration. */ - 'objectql.zod.ts#ObjectGridSchema': 'label' | 'description'; - /** - * SCHEMA-NODE. (`form` left under objectui#7760; `table` did not. Both are inline - * `z.lazy` slots with no exported const — `UNNAMED_LAZY_SLOTS` below records them — - * and neither carries an annotation of its own, so what moved is what they REACH.) - */ - 'objectql.zod.ts#ObjectViewSchema': 'table'; /** * CONCRETE. ENTERED under objectui#7760, unmeasurable before it: the mirror is * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` and the declaration states @@ -2694,7 +2696,6 @@ type WiderArmClass = 'SCHEMA-NODE' | 'CONCRETE'; const WIDER_ARM_ROW_SEPARATOR = '::'; const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { - 'app.zod.ts#AppComponentSchema::label': ['CONCRETE', 'CONCRETE'], 'app.zod.ts#AppComponentSchema::areas': ['SCHEMA-NODE'], 'complex.zod.ts#ChatbotSchema::body': ['CONCRETE'], 'complex.zod.ts#DashboardComponentSchema::header': ['CONCRETE'], @@ -2717,13 +2718,9 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { 'form.zod.ts#SliderSchema::defaultValue': ['CONCRETE', 'CONCRETE'], 'form.zod.ts#SliderSchema::value': ['CONCRETE', 'CONCRETE'], 'layout.zod.ts#ContainerSchema::maxWidth': ['CONCRETE', 'CONCRETE'], - 'layout.zod.ts#PageNodeSchema::aria': ['CONCRETE'], 'layout.zod.ts#PageNodeSchema::slots': ['SCHEMA-NODE'], 'navigation.zod.ts#HeaderBarSchema::logo': ['CONCRETE', 'CONCRETE'], 'navigation.zod.ts#HeaderBarSchema::variant': ['CONCRETE'], - 'objectql.zod.ts#ObjectGridSchema::label': ['CONCRETE', 'CONCRETE'], - 'objectql.zod.ts#ObjectGridSchema::description': ['CONCRETE', 'CONCRETE'], - 'objectql.zod.ts#ObjectViewSchema::table': ['SCHEMA-NODE'], 'overlay.zod.ts#TooltipSchema::content': ['CONCRETE', 'CONCRETE'], 'views.zod.ts#DetailViewFieldSchema::options': ['CONCRETE'], 'views.zod.ts#DetailViewSchema::fields': ['SCHEMA-NODE'], diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 40a3f0e13e..108b410366 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -44,6 +44,7 @@ // `spec-derived-unions.test.ts` pins the three blockers above, each written so // it fails the day the spec closes it. import type { + I18nLabel, NavigationArea as SpecNavigationArea, NavigationItem as SpecNavigationItem, ObjectNavItem as SpecObjectNavItem, @@ -380,9 +381,39 @@ export interface AppComponentSchema extends BaseSchema { title?: string; /** - * Display Label (used in navigation and app switcher) - */ - label?: string; + * Display Label (used in navigation and app switcher). + * + * `string | I18nLabel` — the spec's INLINE locale map (`string | + * Record`), resolved against a BCP-47 display locale by the + * spec's own `resolveI18nLabel(label, locale)`. + * + * This restated the key as a plain `string` until objectui#9092. That was + * narrower than BOTH faces it sits between: `BaseSchema.label` (which + * objectui#4580's revised Q1 ruling, option A, widened to + * `string | I18nLabel`) and this pair's own mirror — `zod/app.zod.ts`'s + * `AppComponentSchema` never restates `label`, so it inherits the zod + * `BaseSchema`'s `I18nLabelSchema`. A restatement is a NARROWING override, + * so the mirror accepted an authored locale map and `tsc` refused it, with + * the narrowing on the DECLARED side where a forward mirror-vs-declaration + * comparison reads it as clean. + * + * ⚠️ NOT the KEYED vocabulary. {@link BaseSchema.ariaLabel} declares + * `string | KeyedI18nLabel` (`{ key, defaultValue?, params? }`, resolved by + * `resolveKeyedI18nLabel`) — objectui#4580 Q2-B withdrew the `I18nLabel` + * spelling there as measured-wrong. The two object shapes are structurally + * confusable to a READER, but neither vocabulary admits the other: + * `InlineLocaleMapSchema` types its map with `key?: never; defaultValue?: + * never`, and its `INLINE_LOCALE_KEY` pattern excludes both names, so writing + * one into the other's slot is refused at `tsc` AND at parse. Asserted both + * ways in `__tests__/inline-locale-declared-face-9092.test.ts`; an earlier + * draft of this docblock said the two shapes "each accept the other + * vacuously", which was true when objectui#4580 Q2-B wrote it and is false + * against the installed pin. What a wrong slot costs is a wrong ANSWER rather + * than a silent acceptance — `resolveI18nLabel` hands a keyed reference back + * as its own `key` string — so still check which resolver owns a slot before + * writing an object into it. + */ + label?: string | I18nLabel; /** * Application Description diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index d03dc79a22..6742e36630 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -16,7 +16,7 @@ * @packageDocumentation */ -import type { PageType as SpecPageType } from '@objectstack/spec/ui'; +import type { I18nLabel, PageType as SpecPageType } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; import type { BreakpointName } from './mobile.js'; @@ -1153,9 +1153,26 @@ export interface PageNodeSchema extends BaseSchema { /** * ARIA accessibility attributes. * Aligned with @objectstack/spec AriaPropsSchema. + * + * `ariaLabel` is `string | I18nLabel` — the spec's INLINE locale map. This is + * the spec's OWN spelling for this slot: `AriaPropsSchema.ariaLabel` is + * `z.union([z.string(), InlineLocaleMapSchema])`, and the spec's `PageSchema` + * carries that object as its own top-level `aria`, so the mirror + * (`zod/layout.zod.ts`'s `PageNodeSchema`) receives it BY REFERENCE through + * `SpecPageFields`. The restatement here stated the string arm alone until + * objectui#9092, so an authored locale map parsed green and `tsc` refused it. + * + * ⚠️ This is the NESTED slot, and its vocabulary is the INLINE one — the same + * split objectui#5134 measured on `ListView`, where the read now goes through + * `resolveI18nLabel` against the display locale. The FLAT + * {@link BaseSchema.ariaLabel} is the OTHER vocabulary (KEYED + * `{ key, defaultValue?, params? }`, resolved by `resolveKeyedI18nLabel` in + * `SchemaRenderer`), and objectui#4580 Q2-B withdrew the `I18nLabel` spelling + * THERE for exactly that reason. Two spellings one level apart; neither + * resolver accepts the other's shape. */ aria?: { - ariaLabel?: string; + ariaLabel?: string | I18nLabel; ariaDescribedBy?: string; role?: string; }; diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index f9d9c1a0f8..6f252e7dad 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -668,9 +668,21 @@ export interface ObjectGridSchema extends BaseSchema { name?: string; /** - * Display label override + * Display label override. + * + * `string | I18nLabel` — the spec's INLINE locale map, resolved against a + * BCP-47 display locale by `resolveI18nLabel(label, locale)`. Plain `string` + * until objectui#9092: a NARROWING override of `BaseSchema.label`, which + * objectui#4580's revised Q1 ruling (option A) widened. The mirror + * (`zod/objectql.zod.ts`'s `ObjectGridSchema`) never restates the key, so it + * inherits the zod `BaseSchema`'s `I18nLabelSchema` and accepted the map all + * along while `tsc` refused it. + * + * ⚠️ NOT the KEYED `{ key, defaultValue?, params? }` vocabulary that + * {@link BaseSchema.ariaLabel} carries; the two are structurally confusable + * and neither resolver accepts the other's shape. */ - label?: string; + label?: string | I18nLabel; /** * ObjectQL object name (e.g., 'users', 'accounts', 'contacts') @@ -845,10 +857,24 @@ export interface ObjectGridSchema extends BaseSchema { title?: string; /** + * Legacy description field. + * + * `string | I18nLabel` — the spec's INLINE locale map, resolved against a + * BCP-47 display locale by `resolveI18nLabel(label, locale)`. Plain `string` + * until objectui#9092: a NARROWING override of `BaseSchema.description`, + * which objectui#4580's revised Q1 ruling (option A) widened. The mirror + * (`zod/objectql.zod.ts`'s `ObjectGridSchema`) never restates the key, so it + * inherits the zod `BaseSchema`'s `I18nLabelSchema`. + * + * ⚠️ The `@deprecated` tag below is NOT a reason to leave the declaration + * narrow: deprecated-but-declared is still an authoring face, and an author + * on it was refused by `tsc` for writing the form the contract publishes. + * Whether the key should exist at all is the ADR-0049 liveness question, not + * this one. + * * @deprecated No direct replacement (consider using label with additional context) - * Legacy description field */ - description?: string; + description?: string | I18nLabel; /** * Enable/disable built-in operations