diff --git a/.changeset/4795-bindable-text-keys.md b/.changeset/4795-bindable-text-keys.md
new file mode 100644
index 0000000000..c650b7b171
--- /dev/null
+++ b/.changeset/4795-bindable-text-keys.md
@@ -0,0 +1,50 @@
+---
+'@object-ui/react': minor
+---
+
+Expression-bindable text keys: `statistic.value`, `card.title`, `button.label`
+and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1,
+maintainer ruling 2026-08-25).
+
+**What changes for you.** Four text keys — `title`, `label`, `value`,
+`description` — can now carry an expression written directly on the component
+node, on the component types that declare them:
+
+| Component | Bindable node keys |
+|---|---|
+| `statistic` | `label`, `value`, `description` |
+| `card` | `title`, `description` |
+| `button` | `label` |
+
+```json
+{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" }
+```
+
+That node used to render the literal text `${data.metrics.active}`. A dashboard
+`statistic` previously had no way at all to bind a dynamic number — the
+documented workaround (moving the key under `props`) evaluated the value and
+then discarded it, painting a blank card instead. Both shapes are fixed by the
+same change: the value is evaluated once, at the single place that produces
+evaluated schema, and lands where the renderers already read.
+
+**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx`
+are untouched — they always read these keys off the node; nothing was writing an
+evaluated value there.
+
+**Scope, and how it grows.** The list is closed and lives in
+`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer
+reads that declaration rather than keeping a copy. On any other component type
+these four keys are still read raw, so an expression reaches the screen as
+literal text — notably `text`, whose `value` is read but has no declaration.
+Adding a type or a key is a change to the spec, never something the renderer
+infers.
+
+**Nothing is newly rejected.** This release only widens what evaluates; no
+metadata that used to render now fails to. The build-time rejection of `${...}`
+in undeclared keys — the second half of the same ruling — is not in this release
+and is still open.
+
+Published authoring guidance updated to match: `skills/objectui/rules/protocol.md`
+(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions`
+and `data-integration` guides, which taught the now-retired "never evaluated"
+statement and its host-pre-resolution workaround.
diff --git a/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx b/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
new file mode 100644
index 0000000000..bd53807b3d
--- /dev/null
+++ b/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
@@ -0,0 +1,105 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#4795 Direction 1 — the same contract as
+ * `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but
+ * driven through the REAL production renderers with no stand-in for the
+ * read-back half.
+ *
+ * That companion file pins the memo against probes, because `@object-ui/react`
+ * by design does not depend on `@object-ui/components`. It can therefore prove
+ * the value was EVALUATED, and only assert the read-back against a mirror of
+ * the real read points. This file closes that gap: `statistic`, `card` and
+ * `button` here are the shipped renderers, so a passing assertion below means
+ * the evaluated value actually reached the DOM — which is the whole of what
+ * objectui#4795 measured as missing ("evaluated AND read back").
+ *
+ * ## Why this file contains no renderer-specific fix to guard
+ *
+ * It guards the OPPOSITE. The ruling's implementation caution was that these
+ * read-back sites must be "converged on evaluated values, not patched per
+ * component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or
+ * `form/button.tsx` is touched by this card. They already read the right place;
+ * the single memo leg upstream now writes an evaluated value there. So these
+ * assertions passing while those three files are untouched IS the convergence
+ * claim, stated as a measurement rather than as a promise.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+// Module scope, not a hook — the cold transform would otherwise be billed to
+// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
+import '../renderers';
+
+const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };
+
+const renderNode = (schema: any) =>
+ render(
+
+
+ ,
+ );
+
+describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
+ it('`statistic` binds label / value / description', () => {
+ renderNode({
+ type: 'statistic',
+ label: '${data.caption}',
+ value: '${data.total}',
+ description: '${data.note}',
+ });
+ expect(screen.getByText('Active users')).toBeTruthy();
+ expect(screen.getByText('99')).toBeTruthy();
+ expect(screen.getByText('+20.1% from last month')).toBeTruthy();
+ // The defect, stated in the negative: the literal source must be gone.
+ expect(screen.queryByText('${data.total}')).toBeNull();
+ });
+
+ it('`statistic` interpolates inside surrounding text', () => {
+ renderNode({ type: 'statistic', value: 'Total: ${data.total}' });
+ expect(screen.getByText('Total: 99')).toBeTruthy();
+ });
+
+ it('`card` binds title / description', () => {
+ renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' });
+ expect(screen.getByText('Active users')).toBeTruthy();
+ expect(screen.getByText('+20.1% from last month')).toBeTruthy();
+ });
+
+ it('`button` binds label', () => {
+ renderNode({ type: 'button', label: 'Refresh ${data.total}' });
+ expect(screen.getByText('Refresh 99')).toBeTruthy();
+ });
+});
+
+describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
+ /**
+ * `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
+ * IS a top-level read-back site — and `text` has no row in the spec's
+ * carriage map, so the memo must not evaluate it. This assertion therefore
+ * pins a KNOWN, reported gap rather than a desired behaviour: the literal on
+ * screen is what an author writing the form the expressions guide teaches
+ * gets today, and closing it is a spec-side row (objectstack), not a
+ * renderer-side inference here. If a row is ever added upstream, this is the
+ * test that will go red and say so.
+ */
+ it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
+ renderNode({ type: 'text', value: '${data.total}' });
+ expect(screen.getByText('${data.total}')).toBeTruthy();
+ });
+
+ it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
+ const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' });
+ // `card` declares title/description only; `value` is neither evaluated nor
+ // read back, so nothing from it reaches the DOM text.
+ expect(container.textContent).toContain('Fixed');
+ expect(container.textContent).not.toContain('99');
+ });
+});
diff --git a/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx b/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx
index c93be1a4e8..1f4f35f4b8 100644
--- a/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx
+++ b/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx
@@ -20,7 +20,20 @@
* - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT
* hoist (`type` / `id` excepted). It therefore lands
* exactly where every renderer reads, in every namespace.
- * - a node key — read, but never expression-evaluated.
+ * - a node key — read, and expression-evaluated only if
+ * `@objectstack/spec` DECLARES it bindable for that
+ * component type (objectui#4795 Direction 1, ruled
+ * 2026-08-25). Every other node key is read raw.
+ *
+ * ⚠️ That third line said a flat "read, but never expression-evaluated" when
+ * this file was written, and the reading below said so too. objectui#4795
+ * closed the gap for the closed set `title` / `label` / `value` /
+ * `description`, per the carriage map in
+ * `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and
+ * `description`. The rest of this file's measurement is untouched: the two
+ * envelope fates above are exactly what #5372 measured, and the card's
+ * question ② (whether `properties` is an official `ui:*` authoring channel)
+ * is still open and still not answered here.
*
* So the one spelling the rules told an author not to reach for was the only
* one that reaches a provider's data, and the two the rules endorsed both fail
@@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => {
expect(header()).toBe('Customer Summary');
});
- it('a node-level `title` is read but never evaluated', () => {
+ // objectui#4795 Direction 1 flipped this reading. It used to assert the
+ // literal `${data.label}` on screen — a node key was read but never
+ // evaluated — and that is the defect the 2026-08-25 ruling retired for the
+ // spec-DECLARED keys. Kept as a pair so the file still states both halves:
+ // a declared key now evaluates, an undeclared one on the same node does not.
+ it('a node-level `title` is read AND evaluated — `card` declares it', () => {
renderNode({ type: 'card', title: '${data.label}' });
- expect(header()).toBe('${data.label}');
+ expect(header()).toBe('Evaluated Title');
+ });
+
+ it('a node key `card` does NOT declare is still read raw (`value`)', () => {
+ renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' });
+ expect(header()).toBe('Fixed');
});
it('the same expression under `properties` is evaluated AND read', () => {
diff --git a/packages/react/package.json b/packages/react/package.json
index 780971cbe1..6b75ea85ea 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
- "@objectstack/spec": "^17.0.0",
+ "@objectstack/spec": "^17.1.0",
"react-hook-form": "^7.85.0"
},
"peerDependencies": {
diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx
index d269cfa673..7f9427de8f 100644
--- a/packages/react/src/SchemaRenderer.tsx
+++ b/packages/react/src/SchemaRenderer.tsx
@@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
+import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
@@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent<
if (typeof newSchema.content === 'string') {
newSchema.content = evaluator.evaluate(newSchema.content);
}
-
+
+ /**
+ * Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
+ * (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
+ *
+ * ## The hole this closes
+ *
+ * A value reaches the screen only if it is BOTH evaluated here AND read
+ * back by the renderer off the node. `content` above satisfies both; the
+ * other text keys satisfied neither at once, so
+ * `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
+ * `schema.value` (`data-display/statistic.tsx`) — put the literal
+ * `${data.total}` on screen, and the `props`-envelope workaround the
+ * objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
+ * then spread as a React prop nobody reads). `statistic` is the dashboard
+ * workhorse and had no way at all to bind a dynamic value.
+ *
+ * ## Why this is ONE leg here and not a patch in each renderer
+ *
+ * The ruling's own implementation caution: the read-back sites must be
+ * "converged on evaluated values, not patched per component". They already
+ * agree on WHERE to read (the node's top level) — what they lacked was
+ * anything writing an evaluated value there. Writing it at the single
+ * producer of evaluated schema means `statistic.tsx`, `card.tsx` and
+ * `button.tsx` are UNCHANGED by this card and every future top-level reader
+ * is covered by construction. Four fixed components and no contract was the
+ * failure mode named at dispatch.
+ *
+ * ## The vocabulary is the spec's, and is consumed rather than copied
+ *
+ * `@objectstack/spec` declares it (objectstack#9599): the closed set
+ * `title` / `label` / `value` / `description`, plus the per-component
+ * carriage map saying which of them each component type actually reads
+ * back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
+ * declaration "rather than hard-coding a twin list" — so the only thing
+ * this file knows is the name of the lookup. A row added upstream starts
+ * working here with no edit; a row this file invented would be the second
+ * dialect the declaration exists to prevent.
+ *
+ * ## The type string is passed VERBATIM
+ *
+ * `expressionBindableTextKeysFor` keys on the bare registry name, and the
+ * spec states the answer for an unlisted type is the empty set — "closed
+ * and mechanically answerable in both directions, never inferred from what
+ * a renderer happens to read". So no prefix-stripping normalization: it
+ * would look harmless (`ui:statistic` → `statistic`) and would in the same
+ * motion grant rows to `element:button` and `page:card`, whose renderers
+ * read their config out of the bag via `readProps()` and never touch these
+ * keys on the node — re-manufacturing the evaluated-but-not-read-back half
+ * of the very table this card exists to close. Measured on this tree: the
+ * authored corpus spells these types bare (`statistic` 42, `card` 135,
+ * `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
+ * spelling authors actually use. `action:button` (5 nodes) has no row and
+ * is reported upstream rather than inferred here.
+ *
+ * ## Ordering and idempotence
+ *
+ * After the `properties` hoist deliberately, exactly like `content`: a
+ * value arriving through that channel was already evaluated by the
+ * `properties` leg, so it no longer carries a `${…}` and
+ * `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
+ * guard is doing real work rather than mirroring the line above — it stops
+ * this loop CREATING an absent key as `undefined`, which would change what
+ * `{ ...schema }` spreads and what `key in schema` answers downstream.
+ */
+ for (const key of expressionBindableTextKeysFor(
+ typeof newSchema.type === 'string' ? newSchema.type : '',
+ )) {
+ if (typeof newSchema[key] === 'string') {
+ newSchema[key] = evaluator.evaluate(newSchema[key]);
+ }
+ }
+
// Evaluate 'props' — the legacy alias of the config bag.
//
// The guard MIRRORS the `properties` branch above rather than testing bare
diff --git a/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx b/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx
new file mode 100644
index 0000000000..c3e58e14d3
--- /dev/null
+++ b/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx
@@ -0,0 +1,196 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#4795 Direction 1 — the top-level text keys the evaluation memo
+ * evaluates, and the ones it deliberately leaves inert.
+ *
+ * ## What was broken
+ *
+ * A value reaches the screen only if it is BOTH evaluated by the memo AND read
+ * back by the renderer off the node. Before this leg the memo evaluated
+ * `content`, the two config bags and the predicate keys — so
+ * `{ type: 'statistic', value: '${data.total}' }`, whose renderer reads
+ * `schema.value`, put the literal `${data.total}` on screen.
+ *
+ * ## The contract this pins, and where it lives
+ *
+ * NOT here. `@objectstack/spec` declares it (objectstack#9599): the closed
+ * vocabulary `EXPRESSION_BINDABLE_TEXT_KEYS` and the per-component carriage map
+ * `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`, read through
+ * `expressionBindableTextKeysFor(type)`. The memo CONSUMES that lookup; this
+ * file pins that it consumes it, never a twin list of its own — so the
+ * assertions below name the spec's exports rather than restating their contents
+ * as literals, and a spec-side row change lands here as a passing test rather
+ * than a red one somebody has to reconcile by hand.
+ *
+ * The one thing stated as a literal is the CLOSED-ness of the vocabulary
+ * itself, which is the maintainer ruling (2026-08-25) rather than a detail:
+ * four keys, and a fifth is a new decision.
+ *
+ * ## The negative half is the load-bearing half
+ *
+ * The spec's answer for a type with no row is the frozen EMPTY set — "closed
+ * and mechanically answerable in both directions, never inferred from what a
+ * renderer happens to read". So a closed-set key on an undeclared type stays
+ * inert, and that is the contract, not a gap this file should paper over: the
+ * `text` / `ui:statistic` cases below fail loudly if a later edit ever widens
+ * the lookup by inference.
+ */
+
+import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import React from 'react';
+import { ComponentRegistry } from '@object-ui/core';
+import {
+ EXPRESSION_BINDABLE_TEXT_KEYS,
+ EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT,
+ expressionBindableTextKeysFor,
+} from '@objectstack/spec/ui';
+import { SchemaRenderer } from '../SchemaRenderer';
+import { SchemaRendererContext } from '../context/SchemaRendererContext';
+
+const DATA = { total: 99, name: 'Widgets', note: 'since last week' };
+
+/**
+ * Probes that read the SAME top-level keys the real renderers read, which is
+ * what makes "evaluated" and "read back" one observation instead of two.
+ * `@object-ui/react` does not depend on `@object-ui/components` by design, so
+ * the read points are mirrored here; the sites they mirror are
+ * `data-display/statistic.tsx` (`schema.label` / `schema.value` /
+ * `schema.description`), `layout/card.tsx` (`schema.title` /
+ * `schema.description`) and `form/button.tsx` (`schema.label`).
+ */
+const TopLevelProbe = ({ schema }: any) => (
+
+);
+
+beforeAll(() => {
+ for (const type of ['statistic', 'card', 'button', 'text', 'ui:statistic', 'constructor']) {
+ // Registered with a namespace, so the registry ALSO creates the bare-name
+ // fallback entry — and the bare name is the spelling these nodes are
+ // authored with, which is the one the memo asks the spec about.
+ ComponentRegistry.register(type, TopLevelProbe, { namespace: 'test-4795' });
+ }
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const renderNode = (schema: any) =>
+ render(
+
+
+ ,
+ );
+
+const read = (key: string) => screen.getByTestId('probe').getAttribute(`data-${key}`);
+
+describe('objectui#4795 — the memo consumes the spec-declared bindable text keys', () => {
+ it('the vocabulary it consumes is the spec\'s closed four, not a local list', () => {
+ expect([...EXPRESSION_BINDABLE_TEXT_KEYS]).toEqual(['title', 'label', 'value', 'description']);
+ });
+
+ describe.each(Object.keys(EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT))(
+ 'declared type `%s`',
+ (type) => {
+ const declared = expressionBindableTextKeysFor(type);
+
+ it(`evaluates exactly its declared keys (${declared.join(', ')}) and reads them back`, () => {
+ const node: any = { type };
+ for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) node[key] = '${data.total}';
+ renderNode(node);
+
+ for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) {
+ if (declared.includes(key as any)) {
+ expect(read(key), `${type}.${key} is declared and must be evaluated`).toBe('99');
+ } else {
+ expect(read(key), `${type}.${key} is NOT declared and must stay inert`).toBe('${data.total}');
+ }
+ }
+ });
+ },
+ );
+
+ it('interpolates inside a surrounding string, like `content` does', () => {
+ renderNode({ type: 'statistic', value: 'Total: ${data.total} (${data.note})' });
+ expect(read('value')).toBe('Total: 99 (since last week)');
+ });
+
+ it('leaves a non-string value untouched', () => {
+ renderNode({ type: 'statistic', value: 0, label: null });
+ expect(read('value')).toBe('0');
+ expect(read('label')).toBe('null');
+ });
+
+ it('leaves a plain string with no template untouched', () => {
+ renderNode({ type: 'card', title: 'Revenue' });
+ expect(read('title')).toBe('Revenue');
+ });
+});
+
+describe('objectui#4795 — the closed set is closed in the OTHER direction too', () => {
+ /**
+ * `text` reads `schema.content || schema.value` (`basic/text.tsx`), so its
+ * `value` IS a top-level read-back site — and it still has no spec row, so
+ * the memo must leave it alone. Declaring it here instead would be exactly
+ * the renderer-side inference the spec module forbids in its own docblock;
+ * the route for it is a spec row, not this file.
+ */
+ it('a closed-set key on an undeclared type stays inert (`text.value`)', () => {
+ expect(expressionBindableTextKeysFor('text')).toHaveLength(0);
+ renderNode({ type: 'text', value: '${data.total}' });
+ expect(read('value')).toBe('${data.total}');
+ });
+
+ /**
+ * The lookup is fed the AUTHORED type string verbatim. The spec keys its map
+ * on the bare registry name, and a namespaced spelling has no row — so it
+ * gets the empty set. Pinned rather than left implicit because the tempting
+ * "just strip the prefix" would silently grant rows to `element:button` and
+ * `page:card`, whose renderers do not read these keys off the node at all.
+ */
+ it('a namespaced spelling is not silently normalized (`ui:statistic`)', () => {
+ expect(expressionBindableTextKeysFor('ui:statistic')).toHaveLength(0);
+ renderNode({ type: 'ui:statistic', value: '${data.total}' });
+ expect(read('value')).toBe('${data.total}');
+ });
+
+ /** A prototype-chain name must not answer with a function off `Object.prototype`. */
+ it('a prototype-chain type name gets the empty set', () => {
+ expect(expressionBindableTextKeysFor('constructor')).toHaveLength(0);
+ renderNode({ type: 'constructor', title: '${data.total}' });
+ expect(read('title')).toBe('${data.total}');
+ });
+});
+
+describe('objectui#4795 — it composes with the config-bag legs already in the memo', () => {
+ /**
+ * `properties.*` is evaluated by its own leg and then hoisted onto the node,
+ * so by the time this leg runs the hoisted value carries no `${…}` left to
+ * evaluate. Re-evaluating it is a no-op (`evaluate` returns a template-free
+ * string as-is) — the same idempotence the `content` leg relies on.
+ */
+ it('a value arriving through the `properties` hoist is not double-evaluated', () => {
+ renderNode({ type: 'statistic', properties: { value: '${data.total}' } });
+ expect(read('value')).toBe('99');
+ });
+
+ it('a top-level key loses to `properties` on the same key, as objectui#5123 rules', () => {
+ renderNode({ type: 'statistic', value: '${data.total}', properties: { value: '${data.name}' } });
+ expect(read('value')).toBe('Widgets');
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6941818824..7aa49d1a53 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2721,7 +2721,7 @@ importers:
specifier: workspace:*
version: link:../types
'@objectstack/spec':
- specifier: ^17.0.0
+ specifier: ^17.1.0
version: 17.2.0(ai@7.0.65(zod@4.4.3))
react:
specifier: 19.2.8
diff --git a/skills/objectui/guides/data-integration.md b/skills/objectui/guides/data-integration.md
index 144ab29cfa..63b4b6b91b 100644
--- a/skills/objectui/guides/data-integration.md
+++ b/skills/objectui/guides/data-integration.md
@@ -239,8 +239,9 @@ is both expression-evaluated and read back by the renderer, and the provider's
}
```
-A `statistic`'s `label` / `value` / `description` are read off the node but are
-**not** expression-evaluated, so give them values the host already resolved:
+A `statistic` declares `label` / `value` / `description` as expression-bindable
+(objectui#4795), so these are evaluated on the node and read back. Host-resolved
+literals work exactly as before:
```json
{
diff --git a/skills/objectui/guides/page-builder.md b/skills/objectui/guides/page-builder.md
index 9cd5b10fe4..4833406a88 100644
--- a/skills/objectui/guides/page-builder.md
+++ b/skills/objectui/guides/page-builder.md
@@ -188,17 +188,18 @@ clear the first gate; only the short list below clears the second.
| `disabled` / `disabledOn` | Boolean expression. Passed as prop to component. |
| `props.*` | Template-evaluated, but handed to the component as React props — a `ui:*` / `page:*` renderer never reads the result back, so the evaluated value is discarded. Only `element:*` components consume it. Do not use it as an expression carrier. |
| `properties.*` | Template-evaluated **and hoisted onto the node**, so unlike `props` the result is read — by every namespace. Its status as an authoring channel is open (objectui#4795); see [`rules/protocol.md`](../rules/protocol.md) before reaching for it. |
+| `title` / `label` / `value` / `description` | Template-evaluated **on the component types that declare them** — `statistic` (`label`/`value`/`description`), `card` (`title`/`description`), `button` (`label`). The list is closed and declared in `@objectstack/spec`; see [`rules/protocol.md`](../rules/protocol.md). |
**NOT evaluated (raw strings passed through):**
| Field | What to do instead |
|-------|-----------|
-| `title` / `label` / `value` / `description` | Read by the renderer, but never template-evaluated — an inline `${...}` reaches the screen as literal text. Moving it under `props` does not help: it gets evaluated there and then dropped. Resolve the value in the host **before** handing the schema to `SchemaRenderer` (the same pattern the i18n guide uses for `t(...)`), or carry it on a `text` node's `content`. |
+| `title` / `label` / `value` / `description` **on any other type** | Read by the renderer, but not template-evaluated there — an inline `${...}` reaches the screen as literal text. Moving it under `props` does not help: it gets evaluated there and then dropped. Resolve the value in the host **before** handing the schema to `SchemaRenderer` (the same pattern the i18n guide uses for `t(...)`), or carry it on a `text` node's `content`. |
| `className` | Not expression-evaluated. Use static Tailwind classes only. |
| `id` | Static string. No expressions. |
-**Correct pattern** — a `statistic`'s text keys sit on the node and carry
-values the host already resolved:
+**Correct pattern** — a `statistic`'s text keys sit on the node. Static values
+work as-is, and so do expressions (`statistic` declares all three):
```json
{
"type": "statistic",
@@ -209,8 +210,8 @@ values the host already resolved:
}
```
-**Correct pattern for a live-bound number** — `content` is the one text key
-that is both evaluated and read:
+**Correct pattern on a type that does NOT declare the key** — `content` is
+evaluated on every component type, so a `text` child carries the binding:
```json
{
"type": "card",
@@ -232,7 +233,8 @@ that is both evaluated and read:
}
```
-**Also wrong (value shows as raw `${...}` text — read, but not evaluated):**
+**Right, since objectui#4795 — `statistic` declares `value` and `label`, so
+these are evaluated on the node and read back:**
```json
{
"type": "statistic",
@@ -241,6 +243,10 @@ that is both evaluated and read:
}
```
+⚠️ The same two keys on a type with no declaration (e.g. `text`) still reach the
+screen as literal `${...}`. The declaring types are listed in
+[`rules/protocol.md`](../rules/protocol.md).
+
For the full expression syntax reference (operators, formula functions, security model), see the `objectui-schema-expressions` skill.
## CSS theming template for third-party apps
diff --git a/skills/objectui/guides/schema-expressions.md b/skills/objectui/guides/schema-expressions.md
index 0c9f63999f..7ea9413227 100644
--- a/skills/objectui/guides/schema-expressions.md
+++ b/skills/objectui/guides/schema-expressions.md
@@ -43,6 +43,7 @@ see "The `props` envelope is evaluated but not read" below.
| `visibleOn` | Condition | boolean | `"visibleOn": "data.permissions.canView"` |
| `disabled` | Condition | boolean | `"disabled": "${form.isSubmitting}"` |
| `disabledOn` | Condition | boolean | `"disabledOn": "!data.hasPermission"` |
+| `title` / `label` / `value` / `description` | Template (`${}`) | Preserves original type | **Declared types only** — `statistic` (`label`/`value`/`description`), `card` (`title`/`description`), `button` (`label`). Closed set, declared in `@objectstack/spec` (objectui#4795). |
**Precedence rule:** `visible` takes priority over `hidden`. If both are present, `visible` wins.
@@ -50,9 +51,10 @@ see "The `props` envelope is evaluated but not read" below.
These top-level schema fields are **not** processed by ExpressionEvaluator:
-- `value`, `label`, `description`, `title` — read by the renderers, but never
- template-evaluated. Resolve them in the host before rendering, or carry the
- bound text on a `text` node's `content`.
+- `value`, `label`, `description`, `title` **on a type that does not declare
+ them** (see the row above) — read by the renderers, but not template-evaluated
+ there. Resolve them in the host before rendering, or carry the bound text on a
+ `text` node's `content`.
- `className` — always a static Tailwind class string
- `id` — always a static string
- `type` — component type identifier
@@ -74,13 +76,14 @@ literal `${...}` on screen for nothing on screen. Put keys on the node.
// ❌ Evaluated, then dropped — renders an empty card
{ "type": "card", "props": { "title": "${data.customer.name}" } }
-// ❌ Read, but never evaluated — renders the literal "${data.customer.name}"
+// ✅ `card` declares `title`, so on the node it is evaluated AND read
{ "type": "card", "title": "${data.customer.name}" }
-// ✅ `content` is the one text key that is both evaluated and read
-{ "type": "card", "title": "Customer", "children": [
- { "type": "text", "content": "${data.customer.name}" }
-] }
+// ❌ `text` does not declare `value` — renders the literal "${data.customer.name}"
+{ "type": "text", "value": "${data.customer.name}" }
+
+// ✅ `content` is evaluated on every component type
+{ "type": "text", "content": "${data.customer.name}" }
```
The `element:*` namespace is where `props` is read: those components take their
diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md
index 555a95293c..ba1341424e 100644
--- a/skills/objectui/rules/protocol.md
+++ b/skills/objectui/rules/protocol.md
@@ -19,6 +19,7 @@ value must clear before it reaches the screen.
| `visibleOn` | Condition | boolean | `"visibleOn": "data.permissions.canView"` |
| `disabled` | Condition | boolean | `"disabled": "${form.isSubmitting}"` |
| `disabledOn` | Condition | boolean | `"disabledOn": "!data.hasPermission"` |
+| `title` / `label` / `value` / `description` | Template (`${}`) | Preserves original type | **Only on the component types the spec declares** — see "Rule: Bindable Text Keys" below. `"value": "${data.total}"` on a `statistic` renders the number. |
| `properties.*` | Template (`${}`) | Preserves original type | Evaluated, then **hoisted onto the node** (`type` / `id` excepted), so the result lands where every renderer reads. See "Rule: Keys Live on the Node" below. |
| `props.*` | Template (`${}`) | Preserves original type | Evaluated, then spread as **React props** — a `ui:*` / `page:*` renderer reads `schema.*` and never sees the result. Consumed only by `element:*` components. |
@@ -28,12 +29,13 @@ value must clear before it reaches the screen.
These top-level schema fields are passed as raw strings:
-- `value`, `label`, `description`, `title` — read by the renderers, but never
- template-evaluated. **Do not "move them to `props`"** to make an expression
- work: under `props` they are evaluated and then discarded, so the component
- paints an empty frame instead. Resolve the value in the host before handing
- the schema to `SchemaRenderer`, or carry it on a `text` node's `content`,
- which is the one text key that is both evaluated and read.
+- `value`, `label`, `description`, `title` — evaluated **only on the component
+ types that declare them** (see "Rule: Bindable Text Keys"); read raw
+ everywhere else. **Do not "move them to `props`"** to make an expression work
+ on a type that does not declare them: under `props` they are evaluated and
+ then discarded, so the component paints an empty frame instead. On an
+ undeclared type, resolve the value in the host before handing the schema to
+ `SchemaRenderer`, or carry it on a `text` node's `content`.
- `className` — always a static Tailwind class string
- `id` — always a static string
- `type` — component type identifier
@@ -60,6 +62,35 @@ interface UIComponent {
}
```
+## Rule: Bindable Text Keys
+
+Four text keys can carry a `${}` expression **on the node itself** — `title`,
+`label`, `value`, `description` — but only on the component types that declare
+them. The list is closed and lives in `@objectstack/spec`
+(`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer reads that
+declaration rather than keeping its own copy.
+
+| Component | Bindable node keys |
+|---|---|
+| `statistic` | `label`, `value`, `description` |
+| `card` | `title`, `description` |
+| `button` | `label` |
+
+**✅ CORRECT — a dashboard number that moves with the data:**
+```json
+{
+ "type": "statistic",
+ "label": "Active users",
+ "value": "${data.metrics.activeUsers}"
+}
+```
+
+On any other component type these four keys are read **raw**: the expression
+reaches the screen as literal text. There is no way to opt a type in from
+metadata — a new type/key pair is a change to the spec's declaration, not
+something the renderer infers. Until then, resolve the value in the host, or
+use a `text` node's `content`, which every type evaluates.
+
## Rule: Keys Live on the Node, Not in a `props` Envelope
Every `ui:*` / `page:*` renderer reads its configuration off the node —