Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/spotty-buttons-narrow-their-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': patch
---

Correct the `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` docblock so it stops promising coverage the lookup does not deliver.

The `button` row cited two objectui renderers as its evidence — `form/button.tsx` and `action/action-button.tsx` — but the map is keyed on the type string exactly as authored, and `action-button.tsx` registers under `action:button`, a different key with no row. Prose only: the map, `expressionBindableTextKeysFor` and every exported type are byte-identical, `action:button` still answers the empty set, and no accept surface widens.

- The `button` row now cites `form/button.tsx` alone.
- A new docblock section records that the table is keyed on the authored type string, so namespace-prefixed spellings (`action:button`, `ui:button`, the `mcp:` family) answer the empty set by construction rather than by oversight — and that prefix-stripping is the wrong repair, because it would in the same motion grant rows to `element:button` and `page:card`, whose renderers never read these keys at the node's top level.
- `action:button` and `ui:button` are recorded as deliberately out on two measured grounds — zero pull in the objectui corpus, and the module's own admission rule that each row arrives with its own measurement — alongside the unchanged reopen path for a named requirement.
28 changes: 27 additions & 1 deletion content/docs/references/ui/expression-bindable-text-keys.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ the evaluated-but-blank half of the objectui#4795 table:
|-------------|----------------------------------------------------------|
| `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`) |
| `button` | `schema.label` (`form/button.tsx`) |

These are the measured motivating cases from objectui#4795 (dashboard
workhorses). Other registered renderers also read keys from this closed set
Expand All @@ -78,6 +78,32 @@ channel as `content` alone and declared `text.value` OUT on those grounds —
this omission is deliberate, not pending measurement. Adding a row is
additive and spec-first; do it here, never as a renderer-side inference.

## Rows are keyed on the AUTHORED type string, prefix and all

`expressionBindableTextKeysFor` does an own-property lookup on the
type string exactly as authored, so a namespace-prefixed spelling —
`action:button`, `ui:button`, the `mcp:` family — is a DIFFERENT key from
the bare name and answers the empty set BY CONSTRUCTION, not by oversight.
Nothing strips a prefix between the two, and nothing should: a stripping
rule would in the same motion grant rows to `element:button` and
`page:card`, whose renderers take their config from the evaluated
`properties` bag and never read these keys at the node's top level.

`action:button` is therefore deliberately OUT, and `ui:button` with it
(objectstack#13672); the `button` row above covers the bare `button`
spelling alone, which is why its citation names `form/button.tsx` only. Two
measured grounds, the same two kinds every other row runs on — zero pull
(the objectui corpus census of 736 JSON documents / 2747 typed nodes finds 5
`action:button` nodes, 0 of them carrying an expression in `label`, and 0
`ui:`-prefixed spellings of any kind), and this module's own admission rule
that a row arrives with its own measurement while a type string with no row
IS the empty set. Not a judgement that the key is inert:
`action/action-button.tsx` renders the author-written `schema.label`
directly (measured 2026-08-31), so the read-back half is real and only the
pull is missing. The reopen path is the ordinary one: a named requirement
for an expression-bound `action:button` label adds a row here, carrying that
row's own measurement.

<Callout type="info">
**Source:** `packages/spec/src/ui/expression-bindable-text-keys.zod.ts`
</Callout>
Expand Down
23 changes: 19 additions & 4 deletions packages/spec/src/ui/expression-bindable-text-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ 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.
// form/button.tsx reads schema.label — and the row covers that BARE
// `button` spelling alone: action/action-button.tsx registers as
// `action:button`, a different key this map deliberately does not
// carry (objectstack#13672; see the module docblock).
expect(EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT).toEqual({
statistic: ['label', 'value', 'description'],
card: ['title', 'description'],
Expand Down Expand Up @@ -110,9 +113,21 @@ describe('expressionBindableTextKeysFor — the mechanical per-type answer', ()

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', '']) {
// config rides the evaluated `properties` bag; and a namespace-prefixed
// spelling is a DIFFERENT key from the bare name, so `action:button` and
// `ui:button` answer empty by construction — the pair the objectstack#13672
// ruling recorded as deliberately out, pinned here so a later prefix-
// stripping "fix" cannot move the machine face in silence. None of them get
// rows inferred from what their renderers happen to read.
for (const type of [
'text',
'element:text',
'page:card',
'action:button',
'ui:button',
'alert',
'',
]) {
const keys = expressionBindableTextKeysFor(type);
expect(keys).toEqual([]);
expect(Object.isFrozen(keys)).toBe(true);
Expand Down
28 changes: 27 additions & 1 deletion packages/spec/src/ui/expression-bindable-text-keys.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
* |-------------|----------------------------------------------------------|
* | `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`) |
* | `button` | `schema.label` (`form/button.tsx`) |
*
* These are the measured motivating cases from objectui#4795 (dashboard
* workhorses). Other registered renderers also read keys from this closed set
Expand All @@ -73,6 +73,32 @@
* channel as `content` alone and declared `text.value` OUT on those grounds —
* this omission is deliberate, not pending measurement. Adding a row is
* additive and spec-first; do it here, never as a renderer-side inference.
*
* ## Rows are keyed on the AUTHORED type string, prefix and all
*
* {@link expressionBindableTextKeysFor} does an own-property lookup on the
* type string exactly as authored, so a namespace-prefixed spelling —
* `action:button`, `ui:button`, the `mcp:` family — is a DIFFERENT key from
* the bare name and answers the empty set BY CONSTRUCTION, not by oversight.
* Nothing strips a prefix between the two, and nothing should: a stripping
* rule would in the same motion grant rows to `element:button` and
* `page:card`, whose renderers take their config from the evaluated
* `properties` bag and never read these keys at the node's top level.
*
* `action:button` is therefore deliberately OUT, and `ui:button` with it
* (objectstack#13672); the `button` row above covers the bare `button`
* spelling alone, which is why its citation names `form/button.tsx` only. Two
* measured grounds, the same two kinds every other row runs on — zero pull
* (the objectui corpus census of 736 JSON documents / 2747 typed nodes finds 5
* `action:button` nodes, 0 of them carrying an expression in `label`, and 0
* `ui:`-prefixed spellings of any kind), and this module's own admission rule
* that a row arrives with its own measurement while a type string with no row
* IS the empty set. Not a judgement that the key is inert:
* `action/action-button.tsx` renders the author-written `schema.label`
* directly (measured 2026-08-31), so the read-back half is real and only the
* pull is missing. The reopen path is the ordinary one: a named requirement
* for an expression-bound `action:button` label adds a row here, carrying that
* row's own measurement.
*/

import { z } from 'zod';
Expand Down
Loading