diff --git a/.changeset/expression-bindable-text-keys.md b/.changeset/expression-bindable-text-keys.md new file mode 100644 index 0000000000..d80cde3605 --- /dev/null +++ b/.changeset/expression-bindable-text-keys.md @@ -0,0 +1,7 @@ +--- +"@objectstack/spec": minor +--- + +Declare the closed vocabulary of expression-bindable text keys (objectui#4795 Direction 1, spec half — #9599). + +`@objectstack/spec/ui` now exports `EXPRESSION_BINDABLE_TEXT_KEYS` (`title` / `label` / `value` / `description` — a closed enum per the 2026-08-17 maintainer ruling's terms, reopened 2026-08-18), the `ExpressionBindableTextKey` type and `ExpressionBindableTextKeySchema` Zod face, the per-component carriage map `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` (`statistic`: `label`/`value`/`description`, `card`: `title`/`description`, `button`: `label` — measured against the objectui renderers' read points at the `.objectui-sha` pin), and the runtime lookup `expressionBindableTextKeysFor(componentType)`. These are consumed by the objectui SchemaRenderer evaluation memo (the downstream half, riding objectui#4795) so the set of top-level text keys the memo evaluates is declared here once, never inferred or hard-coded as a twin list. Purely additive — no existing schema accepts or rejects anything new in this release. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 1c4d5475fe..d52e165136 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -46,7 +46,7 @@ Core business logic and data modeling schemas. | **[Postgres Driver](/docs/references/data/driver-postgres)** | `driver/postgres.zod.ts` | PostgresConfig | PostgreSQL configuration | | **[Mongo Driver](/docs/references/data/driver-mongo)** | `driver/mongo.zod.ts` | MongoConfig | MongoDB configuration | -## UI Protocol (11 of 16 schemas) +## UI Protocol (11 of 17 schemas) Presentation layer - views, forms, dashboards, and themes. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index e357f7fabb..13f0a57890 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1587 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1588 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 161 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1587** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 17 | 162 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **199** | **1588** | 14 protocol modules | --- @@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 161 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **17 pages, 162 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -378,6 +378,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | | [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` | | [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` | +| [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` | | [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` | | [`notification.zod.ts`](/docs/references/ui/notification) | `NotificationPosition`, `NotificationSeverity`, `NotificationType` | | [`page.zod.ts`](/docs/references/ui/page) | `ElementDataSource`, `InterfacePageConfig`, `Page`, `PageComponent`, `PageComponentType`, `PageRegion`, `PageType`, `PageVariable` | diff --git a/content/docs/references/ui/expression-bindable-text-keys.mdx b/content/docs/references/ui/expression-bindable-text-keys.mdx new file mode 100644 index 0000000000..cb17abba6c --- /dev/null +++ b/content/docs/references/ui/expression-bindable-text-keys.mdx @@ -0,0 +1,102 @@ +--- +title: Expression Bindable Text Keys +description: Expression Bindable Text Keys protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Expression-bindable text keys — the CLOSED vocabulary of top-level text keys +a SchemaRenderer evaluation memo evaluates, and the per-component carriage +map that says which component types carry which of them. + +## Provenance (not re-litigable here) + +objectui#4795 measured the hole: apart from `content`, no top-level text key +on a rendered SDUI node was BOTH evaluated by the renderer's expression memo +AND read back by the component renderer — `statistic.value: '${data.n}'` +rendered the literal `${data.n}`, and the `props`-envelope workaround +rendered blank. The 2026-08-17 maintainer ruling on that card deferred the +fix (Direction 1) behind a restart condition and pre-defined its terms, +verbatim: *"the key set is declared in `@objectstack/spec`/types as a closed +enum, never inferred"*. On 2026-08-18 the maintainer ruled the restart +condition met by product intent (a metadata-authored live dashboard is a +basic requirement), reopening Direction 1 on those terms. This module is the +spec half (objectstack#9599); the objectui half extends the evaluation memo +to CONSUME these exports (it rides objectui#4795) instead of hard-coding a +twin list. + +Related, and deliberately out of this module's reach: + + - ⛔ Direction 2 (merging the `props` envelope into the node) is + permanently rejected (same 08-17 ruling) — nothing here re-legalizes the + envelope shape the objectui#4786 teaching rewrite retired. + - `content` is NOT a member. It already has its own evaluation leg in the + memo and its own read-back contract; adding it here would give one key + two declared evaluation paths. + - The `properties` / `props` config bags are evaluated per-value by their + own memo legs (objectui#4799 / #5122) — this vocabulary is only about + keys authored at the NODE'S TOP LEVEL. + +## The contract + +For a component type with a row in +`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`, the renderer's +evaluation memo evaluates exactly the listed keys (when the authored value +is an expression-bearing string); every other key stays inert text. For a +component type with NO row, the answer is the empty set — closed and +mechanically answerable in both directions, never inferred from what a +renderer happens to read. + +## Why these rows (measured, not inferred) + +Rows are a RECORD of what each component renderer already reads back from +the node's top level, measured at the objectui pin (`.objectui-sha` +`82a9417`, re-verified identical at objectui `origin/main` `6c68b13` on +2026-08-18) — declaring a key a renderer does not read back would recreate +the evaluated-but-blank half of the objectui#4795 table: + + | type | renderer read points (top level, within the closed set) | + |-------------|----------------------------------------------------------| + | `statistic` | `schema.label`, `schema.value`, `schema.description` (`data-display/statistic.tsx`) | + | `card` | `schema.title`, `schema.description` (`layout/card.tsx`) | + | `button` | `schema.label` (`form/button.tsx`, `action/action-button.tsx`) | + +These are the measured motivating cases from objectui#4795 (dashboard +workhorses). Other registered renderers also read keys from this closed set +at the top level (`alert`/`empty`/`dialog` `title`+`description`, `badge` +`label`, form inputs' `value`/`label`, …) — those rows are deliberately NOT +declared yet: form-control `value` is interactive state rather than display +text, and each row is an accept-surface widening that should arrive with its +own measurement, not ride this one (startup scope discipline). Adding a row +is additive and spec-first; do it here, never as a renderer-side inference. + + +**Source:** `packages/spec/src/ui/expression-bindable-text-keys.zod.ts` + + +## TypeScript Usage + +```typescript +import { ExpressionBindableTextKeySchema } from '@objectstack/spec/ui'; +import type { ExpressionBindableTextKey } from '@objectstack/spec/ui'; + +// Validate data +const result = ExpressionBindableTextKeySchema.parse(data); +``` + +--- + +## ExpressionBindableTextKey + +One of the closed set of expression-bindable text keys — the top-level node keys a SchemaRenderer evaluation memo may evaluate (objectui#4795 Direction 1; carriage per component type is EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT). + +### Allowed Values + +* `title` +* `label` +* `value` +* `description` + + +--- + diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx index 5e371eab14..19cb0ebd07 100644 --- a/content/docs/references/ui/index.mdx +++ b/content/docs/references/ui/index.mdx @@ -14,6 +14,7 @@ This section contains all protocol schemas for the ui layer of ObjectStack. + diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json index af1834543f..5eedbea53a 100644 --- a/content/docs/references/ui/meta.json +++ b/content/docs/references/ui/meta.json @@ -12,6 +12,7 @@ "component", "dashboard", "dataset", + "expression-bindable-text-keys", "report", "---Interaction & Layout---", "responsive", diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index bccfee4611..972e7560f4 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -135,6 +135,8 @@ "DateRangePreset (type)", "DerivedMeasureOp (const)", "DerivedMeasureOpValue (type)", + "EXPRESSION_BINDABLE_TEXT_KEYS (const)", + "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema (const)", "ElementDataSource (type)", "ElementDataSourceSchema (const)", @@ -150,6 +152,8 @@ "ElementTextPropsSchema (const)", "ExpandViewResult (interface)", "ExpandedViewItem (interface)", + "ExpressionBindableTextKey (type)", + "ExpressionBindableTextKeySchema (const)", "FieldWidgetProps (type)", "FieldWidgetPropsParsed (type)", "FieldWidgetPropsSchema (const)", @@ -422,8 +426,10 @@ "diagnoseViewMetadata (function)", "expandViewContainer (function)", "expandViewContainerWithDiagnostics (function)", + "expressionBindableTextKeysFor (function)", "isActionParamValuePresent (function)", "isAggregatedViewContainer (function)", + "isExpressionBindableTextKey (function)", "isRecordContextBlockType (function)", "isViewContainerShaped (function)", "normalizeFilterOperator (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 238ec96749..23c74a8f86 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -135,6 +135,8 @@ "DateRangePreset": "src/data/date-range-presets.ts#DateRangePreset (type)", "DerivedMeasureOp": "src/ui/dataset.zod.ts#DerivedMeasureOp (const)", "DerivedMeasureOpValue": "src/ui/dataset.zod.ts#DerivedMeasureOpValue (type)", + "EXPRESSION_BINDABLE_TEXT_KEYS": "src/ui/expression-bindable-text-keys.zod.ts#EXPRESSION_BINDABLE_TEXT_KEYS (const)", + "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT": "src/ui/expression-bindable-text-keys.zod.ts#EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema": "src/ui/component.zod.ts#ElementButtonPropsSchema (const)", "ElementDataSource": "src/ui/page.zod.ts#ElementDataSource (type)", "ElementDataSourceSchema": "src/ui/page.zod.ts#ElementDataSourceSchema (const)", @@ -150,6 +152,8 @@ "ElementTextPropsSchema": "src/ui/component.zod.ts#ElementTextPropsSchema (const)", "ExpandViewResult": "src/ui/view.zod.ts#ExpandViewResult (interface)", "ExpandedViewItem": "src/ui/view.zod.ts#ExpandedViewItem (interface)", + "ExpressionBindableTextKey": "src/ui/expression-bindable-text-keys.zod.ts#ExpressionBindableTextKey (type)", + "ExpressionBindableTextKeySchema": "src/ui/expression-bindable-text-keys.zod.ts#ExpressionBindableTextKeySchema (const)", "FieldWidgetProps": "src/ui/widget.zod.ts#FieldWidgetProps (type)", "FieldWidgetPropsParsed": "src/ui/widget.zod.ts#FieldWidgetPropsParsed (type)", "FieldWidgetPropsSchema": "src/ui/widget.zod.ts#FieldWidgetPropsSchema (const)", @@ -422,8 +426,10 @@ "diagnoseViewMetadata": "src/ui/view.zod.ts#diagnoseViewMetadata (function)", "expandViewContainer": "src/ui/view.zod.ts#expandViewContainer (function)", "expandViewContainerWithDiagnostics": "src/ui/view.zod.ts#expandViewContainerWithDiagnostics (function)", + "expressionBindableTextKeysFor": "src/ui/expression-bindable-text-keys.zod.ts#expressionBindableTextKeysFor (function)", "isActionParamValuePresent": "src/ui/action-params.zod.ts#isActionParamValuePresent (function)", "isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)", + "isExpressionBindableTextKey": "src/ui/expression-bindable-text-keys.zod.ts#isExpressionBindableTextKey (function)", "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 7614d4352f..eb2b124e4e 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -60,6 +60,7 @@ "ui/ElementRecordPickerProps", "ui/ElementTextInputProps", "ui/ElementTextProps", + "ui/ExpressionBindableTextKey", "ui/FormButtonConfig", "ui/FormField", "ui/FormFieldPublicPicker", diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 37e8950aff..a7accdcafe 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -544,7 +544,12 @@ const SECTION_GROUPS: Record // #5697), and a reader who found the action declaration should find what a // body receives in the same section rather than under "More". { section: 'Apps & Navigation', pages: ['app', 'page', 'view', 'action', 'action-params'] }, - { section: 'Visualization', pages: ['chart', 'dashboard', 'dataset', 'report', 'widget', 'component'] }, + // `expression-bindable-text-keys` sits beside `component` deliberately: it + // is the SDUI rendering contract's evaluation vocabulary (objectui#4795 + // Direction 1 — which top-level text keys the renderer's expression memo + // evaluates, per component type), and a reader who found the component + // prop schemas should find it in the same section rather than under "More". + { section: 'Visualization', pages: ['chart', 'dashboard', 'dataset', 'report', 'widget', 'component', 'expression-bindable-text-keys'] }, // `animation` / `dnd` / `keyboard` / `touch` / `offline` left this section at // #4988: the five `ui/` interaction config modules were retired whole // (ADR-0049 — no carrier key, nothing parsed them), and their generated diff --git a/packages/spec/src/ui/expression-bindable-text-keys.test.ts b/packages/spec/src/ui/expression-bindable-text-keys.test.ts new file mode 100644 index 0000000000..67b9479387 --- /dev/null +++ b/packages/spec/src/ui/expression-bindable-text-keys.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins for the closed expression-bindable text-key vocabulary (objectui#4795 + * Direction 1, spec half — objectstack#9599). The contract these tests hold: + * + * 1. The key set is CLOSED at the four ruled members — a fifth key appearing + * here must arrive through a maintainer ruling, and this pin is what makes + * that loud instead of a silent accept-surface widening. + * 2. The per-component map answers mechanically for EVERY type string — + * listed rows with their measured subsets, everything else with the empty + * set — and the runtime shape is frozen, because the objectui memo reads + * it at render time. + * 3. `content` stays OUT: it has its own evaluation leg in the memo, and one + * key must not have two declared evaluation paths. + */ +import { describe, it, expect } from 'vitest'; +import { + EXPRESSION_BINDABLE_TEXT_KEYS, + EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT, + ExpressionBindableTextKeySchema, + expressionBindableTextKeysFor, + isExpressionBindableTextKey, +} from './expression-bindable-text-keys.zod'; + +describe('EXPRESSION_BINDABLE_TEXT_KEYS — the closed enum', () => { + it('is exactly the four ruled keys, in the ruling’s order', () => { + expect(EXPRESSION_BINDABLE_TEXT_KEYS).toEqual([ + 'title', + 'label', + 'value', + 'description', + ]); + }); + + it('the Zod face accepts each member and only the members', () => { + for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) { + expect(ExpressionBindableTextKeySchema.safeParse(key).success).toBe(true); + } + // `content` is deliberately excluded (it has its own evaluation leg); + // `header` is a near-miss card key; casing is not forgiven. + for (const nonMember of ['content', 'header', 'Title', 'LABEL', '']) { + expect(ExpressionBindableTextKeySchema.safeParse(nonMember).success).toBe( + false + ); + } + }); + + it('isExpressionBindableTextKey agrees with the enum in both directions', () => { + for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) { + expect(isExpressionBindableTextKey(key)).toBe(true); + } + expect(isExpressionBindableTextKey('content')).toBe(false); + expect(isExpressionBindableTextKey('subtitle')).toBe(false); + }); +}); + +describe('EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT — measured carriage', () => { + it('matches the renderer read points measured at the objectui pin', () => { + // data-display/statistic.tsx reads schema.label / .value / .description; + // layout/card.tsx reads schema.title / .description; + // form/button.tsx (and action/action-button.tsx) read schema.label. + expect(EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT).toEqual({ + statistic: ['label', 'value', 'description'], + card: ['title', 'description'], + button: ['label'], + }); + }); + + it('every declared key is a member of the closed enum, with no duplicates', () => { + for (const [type, keys] of Object.entries( + EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT + )) { + expect(keys.length, `row ${type}`).toBeGreaterThan(0); + expect(new Set(keys).size, `row ${type} has duplicates`).toBe(keys.length); + for (const key of keys) { + expect( + isExpressionBindableTextKey(key), + `row ${type} carries non-member key ${key}` + ).toBe(true); + } + } + }); + + it('the map and its rows are frozen — the contract cannot be mutated by a consumer', () => { + expect(Object.isFrozen(EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT)).toBe( + true + ); + for (const keys of Object.values( + EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT + )) { + expect(Object.isFrozen(keys)).toBe(true); + } + }); +}); + +describe('expressionBindableTextKeysFor — the mechanical per-type answer', () => { + it('returns the declared row for a listed type', () => { + expect(expressionBindableTextKeysFor('statistic')).toEqual([ + 'label', + 'value', + 'description', + ]); + expect(expressionBindableTextKeysFor('card')).toEqual([ + 'title', + 'description', + ]); + expect(expressionBindableTextKeysFor('button')).toEqual(['label']); + }); + + it('answers the empty set for every unlisted type — closed, never inferred', () => { + // `text` binds through its own `content` leg; `element:*` / `page:*` + // config rides the evaluated `properties` bag — none of them get rows + // inferred from what their renderers happen to read. + for (const type of ['text', 'element:text', 'page:card', 'alert', '']) { + const keys = expressionBindableTextKeysFor(type); + expect(keys).toEqual([]); + expect(Object.isFrozen(keys)).toBe(true); + } + }); + + it('prototype-chain names are not rows', () => { + for (const hostile of [ + 'constructor', + '__proto__', + 'toString', + 'hasOwnProperty', + ]) { + expect(expressionBindableTextKeysFor(hostile)).toEqual([]); + } + }); +}); diff --git a/packages/spec/src/ui/expression-bindable-text-keys.zod.ts b/packages/spec/src/ui/expression-bindable-text-keys.zod.ts new file mode 100644 index 0000000000..ff75acdce9 --- /dev/null +++ b/packages/spec/src/ui/expression-bindable-text-keys.zod.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Expression-bindable text keys — the CLOSED vocabulary of top-level text keys + * a SchemaRenderer evaluation memo evaluates, and the per-component carriage + * map that says which component types carry which of them. + * + * ## Provenance (not re-litigable here) + * + * objectui#4795 measured the hole: apart from `content`, no top-level text key + * on a rendered SDUI node was BOTH evaluated by the renderer's expression memo + * AND read back by the component renderer — `statistic.value: '${data.n}'` + * rendered the literal `${data.n}`, and the `props`-envelope workaround + * rendered blank. The 2026-08-17 maintainer ruling on that card deferred the + * fix (Direction 1) behind a restart condition and pre-defined its terms, + * verbatim: *"the key set is declared in `@objectstack/spec`/types as a closed + * enum, never inferred"*. On 2026-08-18 the maintainer ruled the restart + * condition met by product intent (a metadata-authored live dashboard is a + * basic requirement), reopening Direction 1 on those terms. This module is the + * spec half (objectstack#9599); the objectui half extends the evaluation memo + * to CONSUME these exports (it rides objectui#4795) instead of hard-coding a + * twin list. + * + * Related, and deliberately out of this module's reach: + * + * - ⛔ Direction 2 (merging the `props` envelope into the node) is + * permanently rejected (same 08-17 ruling) — nothing here re-legalizes the + * envelope shape the objectui#4786 teaching rewrite retired. + * - `content` is NOT a member. It already has its own evaluation leg in the + * memo and its own read-back contract; adding it here would give one key + * two declared evaluation paths. + * - The `properties` / `props` config bags are evaluated per-value by their + * own memo legs (objectui#4799 / #5122) — this vocabulary is only about + * keys authored at the NODE'S TOP LEVEL. + * + * ## The contract + * + * For a component type with a row in + * {@link EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT}, the renderer's + * evaluation memo evaluates exactly the listed keys (when the authored value + * is an expression-bearing string); every other key stays inert text. For a + * component type with NO row, the answer is the empty set — closed and + * mechanically answerable in both directions, never inferred from what a + * renderer happens to read. + * + * ## Why these rows (measured, not inferred) + * + * Rows are a RECORD of what each component renderer already reads back from + * the node's top level, measured at the objectui pin (`.objectui-sha` + * `82a9417`, re-verified identical at objectui `origin/main` `6c68b13` on + * 2026-08-18) — declaring a key a renderer does not read back would recreate + * the evaluated-but-blank half of the objectui#4795 table: + * + * | type | renderer read points (top level, within the closed set) | + * |-------------|----------------------------------------------------------| + * | `statistic` | `schema.label`, `schema.value`, `schema.description` (`data-display/statistic.tsx`) | + * | `card` | `schema.title`, `schema.description` (`layout/card.tsx`) | + * | `button` | `schema.label` (`form/button.tsx`, `action/action-button.tsx`) | + * + * These are the measured motivating cases from objectui#4795 (dashboard + * workhorses). Other registered renderers also read keys from this closed set + * at the top level (`alert`/`empty`/`dialog` `title`+`description`, `badge` + * `label`, form inputs' `value`/`label`, …) — those rows are deliberately NOT + * declared yet: form-control `value` is interactive state rather than display + * text, and each row is an accept-surface widening that should arrive with its + * own measurement, not ride this one (startup scope discipline). Adding a row + * is additive and spec-first; do it here, never as a renderer-side inference. + */ + +import { z } from 'zod'; + +/** + * The closed key vocabulary, per the 08-17 ruling's terms. Order is the + * ruling's own listing order; the enum is CLOSED — growing it is a maintainer + * decision (a new ruling), not a patch. + */ +export const EXPRESSION_BINDABLE_TEXT_KEYS = [ + 'title', + 'label', + 'value', + 'description', +] as const; + +export type ExpressionBindableTextKey = + (typeof EXPRESSION_BINDABLE_TEXT_KEYS)[number]; + +/** Validating face of the closed vocabulary. */ +export const ExpressionBindableTextKeySchema = z + .enum(EXPRESSION_BINDABLE_TEXT_KEYS) + .describe( + 'One of the closed set of expression-bindable text keys — the top-level node keys a SchemaRenderer evaluation memo may evaluate (objectui#4795 Direction 1; carriage per component type is EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT).' + ); + +export function isExpressionBindableTextKey( + key: string +): key is ExpressionBindableTextKey { + return (EXPRESSION_BINDABLE_TEXT_KEYS as readonly string[]).includes(key); +} + +/** + * Component type → the subset of {@link EXPRESSION_BINDABLE_TEXT_KEYS} its + * renderer both evaluates and reads back. Runtime-readable on purpose: the + * objectui memo consumes this map (via {@link expressionBindableTextKeysFor}), + * it does not keep a twin list. Frozen — the map IS the contract, and a + * consumer mutating its copy would fork it silently. + */ +export const EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT = Object.freeze({ + statistic: Object.freeze(['label', 'value', 'description'] as const), + card: Object.freeze(['title', 'description'] as const), + button: Object.freeze(['label'] as const), +}) satisfies Readonly< + Record +>; + +const NO_EXPRESSION_BINDABLE_TEXT_KEYS: readonly ExpressionBindableTextKey[] = + Object.freeze([]); + +/** + * The mechanical per-component answer: which of the closed keys does the + * evaluation memo evaluate for `componentType`? Unlisted types get the frozen + * empty set — that IS the contract for them (closed, never inferred), not a + * tolerant fallback. Guarded with an own-property check so prototype-chain + * names (`'constructor'`, `'toString'`, …) arriving as author-controlled type + * strings cannot answer with a function off `Object.prototype`. + */ +export function expressionBindableTextKeysFor( + componentType: string +): readonly ExpressionBindableTextKey[] { + if ( + Object.prototype.hasOwnProperty.call( + EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT, + componentType + ) + ) { + return EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT[ + componentType as keyof typeof EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT + ]; + } + return NO_EXPRESSION_BINDABLE_TEXT_KEYS; +} diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 51bc61bf76..97ec9a2123 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -76,3 +76,9 @@ export * from './sharing.zod'; // validated dead slot is the more convincing lie (#4583). Absence and survival // are both pinned in `interaction-config-retirement.test.ts`. // --------------------------------------------------------------------------- + +// The closed expression-bindable text-key vocabulary (objectui#4795 +// Direction 1, spec half — #9599): the four keys a SchemaRenderer evaluation +// memo may evaluate at a node's top level, plus the per-component carriage +// map the objectui memo consumes instead of hard-coding a twin list. +export * from './expression-bindable-text-keys.zod';