From 18078e9b0ce7dc697075b4798ab9f8e9fe975f29 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sat, 15 Aug 2026 01:12:10 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat(bindings):=20the=20translation=20con?= =?UTF-8?q?tract=20=E2=80=94=20every=20target=20accounts=20for=20the=20voc?= =?UTF-8?q?abulary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bindings package now exports the contract types: HandlerKey/AttrKey (derived from EventBindings/AttrBindings) and HandlerTargets/AttrTargets — Records over the closed vocabulary where every key names the host prop that carries it, or null: a declared drop for a binding the substrate cannot express. Every target's normalize declares its maps against the contract, so a new vocabulary key is a compile error in every target until it decides — mapped or dropped, never silently leaked through the unknown-key passthrough (which remains, but only for keys outside the vocabulary). No behavior change: the ad-hoc HANDLER_DROP/ATTR_DROP sets fold into null entries, native's accessibilityState fold derives its key set from the ledger, and the two implicit passthroughs (native role, opentui disabled) become explicit renames. A conformance test per target walks its ledger and asserts every binding lands on its declared target — or, for a null, nowhere at all. Co-Authored-By: Claude Fable 5 --- .changeset/bindings-translation-contract.md | 35 +++++ ARCHITECTURE.md | 16 +-- packages/native/package.json | 3 + packages/native/src/normalize.ts | 140 ++++++++++++-------- packages/native/tests/normalize.test.ts | 23 ++++ packages/opentui/package.json | 3 + packages/opentui/src/normalize.ts | 140 +++++++++++--------- packages/opentui/tests/normalize.test.ts | 23 ++++ packages/react/package.json | 1 + packages/react/src/normalize.ts | 16 ++- packages/react/tests/normalize.test.ts | 23 ++++ packages/shared/bindings/src/index.ts | 15 +++ pnpm-lock.yaml | 13 ++ 13 files changed, 319 insertions(+), 132 deletions(-) create mode 100644 .changeset/bindings-translation-contract.md diff --git a/.changeset/bindings-translation-contract.md b/.changeset/bindings-translation-contract.md new file mode 100644 index 0000000..1c7bc60 --- /dev/null +++ b/.changeset/bindings-translation-contract.md @@ -0,0 +1,35 @@ +--- +'@dunky.dev/state-machine-bindings': minor +'@dunky.dev/react-state-machine': patch +'@dunky.dev/native-state-machine': patch +'@dunky.dev/opentui-state-machine': patch +--- + +The translation contract: every target accounts for the full vocabulary. + +The bindings package now exports `HandlerKey`/`AttrKey` (derived from +`EventBindings`/`AttrBindings`) and the contract types `HandlerTargets`/ +`AttrTargets` — `Record`s over the closed vocabulary where every key names the +host prop that carries it, or is `null`: a declared drop for a binding the +substrate cannot express. + +Each target's normalize declares its maps against the contract: + +```ts +export const HANDLER_MAP = { + onPress: 'onPress', + onWheel: null, // no RN analog — declared, not forgotten + // ...every other vocabulary key, required by the type +} satisfies HandlerTargets +``` + +Adding a key to the vocabulary now breaks every target's typecheck until that +target decides — mapped or dropped, never silently leaked to the host through +the unknown-key passthrough (which still exists, but only for keys outside the +vocabulary, e.g. `data-state`). The react/native/opentui normalizers adopt the +contract with no behavior change: the ad-hoc `HANDLER_DROP`/`ATTR_DROP` sets +fold into `null` entries, native's `accessibilityState` fold derives its key +set from the ledger, and the two implicit passthroughs (native `role`, opentui +`disabled`) become explicit renames. A conformance test per target walks its +ledger and asserts every binding lands on its declared target — or, for a +`null`, nowhere at all. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 82600ae..9ffbab6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -174,11 +174,11 @@ whether it needs props/platform or not: ## Vocabulary -| Term | What it is | -| ------------ | ------------------------------------------------------------------------------------------------------------------------- | -| **host** | The agnostic core — `packages/core/*`. Declares what behavior is. | -| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `native`, `opentui`, …). | -| **machine** | A state-graph config consumed by `machine()`; returns a startable service. | -| **connect** | A function returning the logical surface a view spreads onto elements. | -| **bindings** | The substrate-agnostic event + attr vocabulary — lives in `shared/bindings`, consumed by every target's normalize. | -| **compose** | Run several machines as one unit (orthogonal regions): bundled `start`/`stop` + `sync` + `combine`. | +| Term | What it is | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **host** | The agnostic core — `packages/core/*`. Declares what behavior is. | +| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `native`, `opentui`, …). | +| **machine** | A state-graph config consumed by `machine()`; returns a startable service. | +| **connect** | A function returning the logical surface a view spreads onto elements. | +| **bindings** | The substrate-agnostic event + attr vocabulary — lives in `shared/bindings`, consumed by every target's normalize. Each normalize `satisfies` the translation contract (`HandlerTargets`/`AttrTargets`): every vocabulary key is mapped or a declared `null` drop, so a new binding is a compile error in every target until it decides — never a silent leak. | +| **compose** | Run several machines as one unit (orthogonal regions): bundled `start`/`stop` + `sync` + `combine`. | diff --git a/packages/native/package.json b/packages/native/package.json index df96a31..2c8d8b5 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -38,6 +38,9 @@ "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^" }, + "devDependencies": { + "@dunky.dev/state-machine-bindings": "workspace:^" + }, "peerDependencies": { "react": "*", "react-native": "*" diff --git a/packages/native/src/normalize.ts b/packages/native/src/normalize.ts index 4d19a3e..24c1d83 100644 --- a/packages/native/src/normalize.ts +++ b/packages/native/src/normalize.ts @@ -4,8 +4,9 @@ * How the maps are set up — and where each side comes from: * - Input keys are the substrate-agnostic vocabulary a connect() emits: * `EventBindings` / `AttrBindings` in `@dunky.dev/state-machine-bindings`. - * Every vocabulary key must be accounted for here — mapped, folded, or - * deliberately dropped; an unlisted key would leak to the host untranslated. + * Every vocabulary key must be accounted for here — mapped, folded, or a + * declared `null` drop; the `HandlerTargets`/`AttrTargets` contract makes an + * unlisted key a compile error instead of a silent leak to the host. * - Output keys are verified against RN's own vendored source, not its docs: * - `ReactAndroid/.../uimanager/BaseViewManager.java` — the `@ReactProp` * setters: which view props exist on Android and how each validates. @@ -38,7 +39,11 @@ * it (e.g. 'dialog'). */ -const HANDLER_MAP: Record = { +import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings' + +// The translation contract: every vocabulary key must appear — mapped or a +// declared `null` drop — so a new binding fails here until this target decides. +export const HANDLER_MAP: HandlerTargets = { onPress: 'onPress', onPointerDown: 'onPressIn', onPointerUp: 'onPressOut', @@ -48,21 +53,18 @@ const HANDLER_MAP: Record = { onContextMenu: 'onLongPress', onScroll: 'onScroll', onScrollEnd: 'onMomentumScrollEnd', + // No RN analog — declared drops. + onPointerEnter: null, + onPointerLeave: null, + onPointerMove: null, + onPointerCancel: null, + onKeyDown: null, + onKeyUp: null, + onDoublePress: null, + onWheel: null, } -// No RN analog — stripped. -const HANDLER_DROP = new Set([ - 'onPointerEnter', - 'onPointerLeave', - 'onPointerMove', - 'onPointerCancel', - 'onKeyDown', - 'onKeyUp', - 'onDoublePress', - 'onWheel', -]) - -const ATTR_MAP: Record = { +export const ATTR_MAP: AttrTargets = { // Android-only (iOS has no id-reference labelling); the setter takes a // nativeID string or an array (first element wins). labelledBy: 'accessibilityLabelledBy', @@ -72,45 +74,68 @@ const ATTR_MAP: Record = { // per platform (accessibilityElementsHidden on iOS, no-hide-descendants on // Android) — accessibilityState has no hidden slot. hidden: 'aria-hidden', - // `role` is deliberately absent — it passes through as RN's `role` prop. - // `live` needs a value transform ('off' → 'none'), handled inline in normalize(). + // RN's web-aligned `role` prop takes the full ARIA vocabulary and degrades + // gracefully (never the legacy accessibilityRole enum, which throws). + role: 'role', + + // Folded and special channels — named here for the ledger; normalize() + // routes them before the plain-rename lookup. + disabled: 'accessibilityState', + expanded: 'accessibilityState', + selected: 'accessibilityState', + checked: 'accessibilityState', + busy: 'accessibilityState', + valueMin: 'accessibilityValue', + valueMax: 'accessibilityValue', + valueNow: 'accessibilityValue', + valueText: 'accessibilityValue', + focusable: 'focusable', // value coerced; also sets `accessible` + live: 'accessibilityLiveRegion', // value transform: ARIA 'off' → RN 'none' + + // No clean RN analog — declared drops. `describedBy` included: RN has no + // describe-by-reference slot (no aria-describedby); routing it into the + // label slot would misname the element and clobber labelledBy. + describedBy: null, + controls: null, + hasPopup: null, + modal: null, + pressed: null, + current: null, + invalid: null, + required: null, + readOnly: null, + activeDescendant: null, + errorMessage: null, + owns: null, + orientation: null, + sort: null, + autoComplete: null, + multiline: null, + multiSelectable: null, + level: null, + posInSet: null, + setSize: null, + colCount: null, + colIndex: null, + colSpan: null, + rowCount: null, + rowIndex: null, + rowSpan: null, + atomic: null, } -// No clean RN analog — stripped. `describedBy` included: RN has no -// describe-by-reference slot (no aria-describedby); routing it into the -// label slot would misname the element and clobber labelledBy. -const ATTR_DROP = new Set([ - 'describedBy', - 'controls', - 'hasPopup', - 'modal', - 'pressed', - 'current', - 'invalid', - 'required', - 'readOnly', - 'activeDescendant', - 'errorMessage', - 'owns', - 'orientation', - 'sort', - 'autoComplete', - 'multiline', - 'multiSelectable', - 'level', - 'posInSet', - 'setSize', - 'colCount', - 'colIndex', - 'colSpan', - 'rowCount', - 'rowIndex', - 'rowSpan', - 'atomic', -]) - -// RN's accessibilityState slots — exactly these; anything else is stored and ignored. -const A11Y_STATE_KEYS = new Set(['disabled', 'expanded', 'selected', 'checked', 'busy']) +// String-indexable views for the normalize loop (the ledgers are keyed by the +// closed vocabulary; the loop sees arbitrary keys). +const HANDLERS: Record = HANDLER_MAP +const ATTRS: Record = ATTR_MAP + +// RN's accessibilityState slots — exactly these; anything else is stored and +// ignored. Derived from the ledger so the fold can't drift from it. +const A11Y_STATE_KEYS = new Set( + Object.entries(ATTR_MAP) + .filter(([, target]) => target === 'accessibilityState') + .map(([key]) => key), +) // Logical key → RN's accessibilityValue sub-key (`{ min, max, now, text }`). const A11Y_VALUE_KEYS: Record = { @@ -159,10 +184,8 @@ export function normalize(logical: Bindings): Record { for (const [key, value] of Object.entries(logical)) { if (value === undefined) continue - if (HANDLER_DROP.has(key)) continue - if (ATTR_DROP.has(key)) continue - - const handler = HANDLER_MAP[key] + const handler = HANDLERS[key] + if (handler === null) continue if (handler) { const adapt = PAYLOAD_ADAPTERS[key] out[handler] = adapt ? (arg: unknown) => (value as (p: unknown) => void)(adapt(arg)) : value @@ -193,7 +216,8 @@ export function normalize(logical: Bindings): Record { continue } - const attr = ATTR_MAP[key] + const attr = ATTRS[key] + if (attr === null) continue if (attr) { out[attr] = value continue diff --git a/packages/native/tests/normalize.test.ts b/packages/native/tests/normalize.test.ts index 82df0fb..2a0b522 100644 --- a/packages/native/tests/normalize.test.ts +++ b/packages/native/tests/normalize.test.ts @@ -13,6 +13,7 @@ */ import { describe, expect, it, vi } from 'vitest' import { normalize } from '@dunky.dev/native-state-machine' +import { ATTR_MAP, HANDLER_MAP } from '../src/normalize' describe('native normalize — handlers', () => { it('keeps onPress as-is (RN Pressable.onPress)', () => { @@ -307,3 +308,25 @@ describe('native normalize — realistic slider shape', () => { expect(onValueChange).toHaveBeenCalledWith({ value: 60 }) }) }) + +describe('native normalize — vocabulary accounting (the translation contract)', () => { + it('routes every handler to its declared target; a null drop leaks nothing', () => { + for (const [key, target] of Object.entries(HANDLER_MAP)) { + const out = normalize({ [key]: vi.fn() }) + // landed: the declared target carries it (null = nothing at all); + // leaked: the logical key survived a rename or a drop. + const landed = target === null ? Object.keys(out).length === 0 : target in out + const leaked = target !== key && key in out + expect({ key, landed, leaked }).toEqual({ key, landed: true, leaked: false }) + } + }) + + it('routes every attr to its declared target; a null drop leaks nothing', () => { + for (const [key, target] of Object.entries(ATTR_MAP)) { + const out = normalize({ [key]: key === 'live' ? 'polite' : true }) + const landed = target === null ? Object.keys(out).length === 0 : target in out + const leaked = target !== key && key in out + expect({ key, landed, leaked }).toEqual({ key, landed: true, leaked: false }) + } + }) +}) diff --git a/packages/opentui/package.json b/packages/opentui/package.json index b9fd912..5eccfce 100644 --- a/packages/opentui/package.json +++ b/packages/opentui/package.json @@ -35,5 +35,8 @@ }, "dependencies": { "@dunky.dev/state-machine-utils": "workspace:^" + }, + "devDependencies": { + "@dunky.dev/state-machine-bindings": "workspace:^" } } diff --git a/packages/opentui/src/normalize.ts b/packages/opentui/src/normalize.ts index 0972aa9..caa4071 100644 --- a/packages/opentui/src/normalize.ts +++ b/packages/opentui/src/normalize.ts @@ -10,7 +10,11 @@ * - `focusable` passes through as-is. */ -const HANDLER_MAP: Record = { +import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings' + +// The translation contract: every vocabulary key must appear — mapped or a +// declared `null` drop — so a new binding fails here until this target decides. +export const HANDLER_MAP: HandlerTargets = { onPress: 'onMouseDown', // no synthetic click — a press is a button-down onPointerDown: 'onMouseDown', onPointerUp: 'onMouseUp', @@ -20,65 +24,72 @@ const HANDLER_MAP: Record = { onKeyDown: 'onKeyDown', onValueChange: 'onChange', onWheel: 'onMouseScroll', + // No OpenTUI analog — declared drops. `onFocus`/`onBlur`: OpenTUI signals + // focus via the `focused` prop. `onScroll`/`onScrollEnd`: scrollbox has no + // scroll-position callback. `onKeyUp`: terminals deliver presses, not up/down. + onPointerCancel: null, + onContextMenu: null, + onDoublePress: null, + onKeyUp: null, + onScroll: null, + onScrollEnd: null, + onFocus: null, + onBlur: null, +} + +export const ATTR_MAP: AttrTargets = { + // Visual analogs, routed in normalize(): `hidden` inverts into `visible`, + // `focusable` is coerced to boolean. + hidden: 'visible', + focusable: 'focusable', + disabled: 'disabled', + // No ARIA tree in a terminal — the entire ARIA vocabulary is a declared drop. + id: null, + describedBy: null, + labelledBy: null, + controls: null, + expanded: null, + selected: null, + modal: null, + hasPopup: null, + role: null, + label: null, + checked: null, + pressed: null, + current: null, + busy: null, + invalid: null, + required: null, + readOnly: null, + activeDescendant: null, + errorMessage: null, + owns: null, + valueMin: null, + valueMax: null, + valueNow: null, + valueText: null, + orientation: null, + sort: null, + autoComplete: null, + multiline: null, + multiSelectable: null, + level: null, + posInSet: null, + setSize: null, + colCount: null, + colIndex: null, + colSpan: null, + rowCount: null, + rowIndex: null, + rowSpan: null, + live: null, + atomic: null, } -// No OpenTUI analog — stripped. `onFocus`/`onBlur` dropped: OpenTUI signals focus via the -// `focused` prop. `onScroll`/`onScrollEnd` dropped: scrollbox has no scroll-position callback. -const HANDLER_DROP = new Set([ - 'onPointerCancel', - 'onContextMenu', - 'onDoublePress', - 'onKeyUp', - 'onScroll', - 'onScrollEnd', - 'onFocus', - 'onBlur', -]) - -// No ARIA tree in a terminal — entire ARIA vocabulary dropped. -// `hidden` and `disabled` are NOT here; they have visual analogs handled inline. -const ATTR_DROP = new Set([ - 'id', - 'describedBy', - 'labelledBy', - 'controls', - 'expanded', - 'selected', - 'modal', - 'hasPopup', - 'role', - 'label', - 'checked', - 'pressed', - 'current', - 'busy', - 'invalid', - 'required', - 'readOnly', - 'activeDescendant', - 'errorMessage', - 'owns', - 'valueMin', - 'valueMax', - 'valueNow', - 'valueText', - 'orientation', - 'sort', - 'autoComplete', - 'multiline', - 'multiSelectable', - 'level', - 'posInSet', - 'setSize', - 'colCount', - 'colIndex', - 'colSpan', - 'rowCount', - 'rowIndex', - 'rowSpan', - 'live', - 'atomic', -]) +// String-indexable views for the normalize loop (the ledgers are keyed by the +// closed vocabulary; the loop sees arbitrary keys). +const HANDLERS: Record = HANDLER_MAP +const ATTRS: Record = ATTR_MAP // Adapters are variadic — 's onChange fires `(index, option)`, not a single arg. @@ -122,7 +125,7 @@ export function normalize(logical: Bindings): Record { for (const [key, value] of Object.entries(logical)) { if (value === undefined) continue - const handler = HANDLERS[key] + const handler = ANY_HANDLERS[key] if (handler === null) continue if (handler) { const adapt = PAYLOAD_ADAPTERS[key] @@ -142,7 +145,7 @@ export function normalize(logical: Bindings): Record { continue } - const attr = ATTRS[key] + const attr = ANY_ATTRS[key] if (attr === null) continue if (attr) { out[attr] = value diff --git a/packages/react/src/normalize.ts b/packages/react/src/normalize.ts index f88f2f5..6ecdb1b 100644 --- a/packages/react/src/normalize.ts +++ b/packages/react/src/normalize.ts @@ -1,5 +1,10 @@ // Translate the machine layer's logical surface to React DOM props. -import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings' +import type { + AnyAttrTargets, + AnyHandlerTargets, + AttrTargets, + HandlerTargets, +} from '@dunky.dev/state-machine-bindings' // The translation contract: every vocabulary key must appear — mapped or a // declared `null` drop — so a new binding fails here until this target decides. @@ -123,10 +128,8 @@ export const ATTR_MAP: AttrTargets = { atomic: 'aria-atomic', } -// String-indexable views for the normalize loop (the ledgers are keyed by the -// closed vocabulary; the loop sees arbitrary keys). -const HANDLERS: Record = HANDLER_MAP -const ATTRS: Record = ATTR_MAP +const ANY_HANDLERS: AnyHandlerTargets = HANDLER_MAP +const ANY_ATTRS: AnyAttrTargets = ATTR_MAP export type Bindings = Record @@ -135,14 +138,14 @@ export function normalize(logical: Bindings): Record { for (const [key, value] of Object.entries(logical)) { if (value === undefined) continue - const handler = HANDLERS[key] + const handler = ANY_HANDLERS[key] if (handler) { const adapt = PAYLOAD_ADAPTERS[key] out[handler] = adapt ? (e: AnyEvent) => (value as (p: unknown) => void)(adapt(e)) : value continue } - const attr = ATTRS[key] + const attr = ANY_ATTRS[key] if (attr) { out[attr] = key === 'focusable' ? (value ? 0 : -1) : value continue diff --git a/packages/shared/bindings/src/index.ts b/packages/shared/bindings/src/index.ts index 414524e..013b534 100644 --- a/packages/shared/bindings/src/index.ts +++ b/packages/shared/bindings/src/index.ts @@ -247,3 +247,11 @@ export type AttrKey = keyof AttrBindings */ export type HandlerTargets = Record export type AttrTargets = Record + +/** + * String-indexable views of the ledgers, for a normalize loop: the ledgers + * are keyed by the closed vocabulary, but the loop sees arbitrary keys + * (`undefined` = outside the vocabulary, passes through). + */ +export type AnyHandlerTargets = Record +export type AnyAttrTargets = Record From 07ef03f60b865e71842b01b692c6f1e64557fc0e Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sat, 15 Aug 2026 01:23:45 +0200 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20one=20const=20per=20ledger=20?= =?UTF-8?q?=E2=80=94=20widened=20annotation=20+=20satisfies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exported map is both things at once: annotated AnyHandlerTargets / AnyAttrTargets (string-indexable, so the loop reads it directly) and `satisfies` the contract (so the literal stays exhaustive and excess-checked). The separate widened view consts disappear. Co-Authored-By: Claude Fable 5 --- packages/native/src/normalize.ts | 15 ++++++--------- packages/opentui/src/normalize.ts | 15 ++++++--------- packages/react/src/normalize.ts | 15 ++++++--------- packages/shared/bindings/src/index.ts | 13 ++++++++----- 4 files changed, 26 insertions(+), 32 deletions(-) diff --git a/packages/native/src/normalize.ts b/packages/native/src/normalize.ts index 2e45dca..0ced37f 100644 --- a/packages/native/src/normalize.ts +++ b/packages/native/src/normalize.ts @@ -48,7 +48,7 @@ import type { // The translation contract: every vocabulary key must appear — mapped or a // declared `null` drop — so a new binding fails here until this target decides. -export const HANDLER_MAP: HandlerTargets = { +export const HANDLER_MAP: AnyHandlerTargets = { onPress: 'onPress', onPointerDown: 'onPressIn', onPointerUp: 'onPressOut', @@ -67,9 +67,9 @@ export const HANDLER_MAP: HandlerTargets = { onKeyUp: null, onDoublePress: null, onWheel: null, -} +} satisfies HandlerTargets -export const ATTR_MAP: AttrTargets = { +export const ATTR_MAP: AnyAttrTargets = { // Android-only (iOS has no id-reference labelling); the setter takes a // nativeID string or an array (first element wins). labelledBy: 'accessibilityLabelledBy', @@ -127,10 +127,7 @@ export const ATTR_MAP: AttrTargets = { rowIndex: null, rowSpan: null, atomic: null, -} - -const ANY_HANDLERS: AnyHandlerTargets = HANDLER_MAP -const ANY_ATTRS: AnyAttrTargets = ATTR_MAP +} satisfies AttrTargets // RN's accessibilityState slots — exactly these; anything else is stored and // ignored. Derived from the ledger so the fold can't drift from it. @@ -187,7 +184,7 @@ export function normalize(logical: Bindings): Record { for (const [key, value] of Object.entries(logical)) { if (value === undefined) continue - const handler = ANY_HANDLERS[key] + const handler = HANDLER_MAP[key] if (handler === null) continue if (handler) { const adapt = PAYLOAD_ADAPTERS[key] @@ -219,7 +216,7 @@ export function normalize(logical: Bindings): Record { continue } - const attr = ANY_ATTRS[key] + const attr = ATTR_MAP[key] if (attr === null) continue if (attr) { out[attr] = value diff --git a/packages/opentui/src/normalize.ts b/packages/opentui/src/normalize.ts index 0e87abe..42fd90f 100644 --- a/packages/opentui/src/normalize.ts +++ b/packages/opentui/src/normalize.ts @@ -19,7 +19,7 @@ import type { // The translation contract: every vocabulary key must appear — mapped or a // declared `null` drop — so a new binding fails here until this target decides. -export const HANDLER_MAP: HandlerTargets = { +export const HANDLER_MAP: AnyHandlerTargets = { onPress: 'onMouseDown', // no synthetic click — a press is a button-down onPointerDown: 'onMouseDown', onPointerUp: 'onMouseUp', @@ -40,9 +40,9 @@ export const HANDLER_MAP: HandlerTargets = { onScrollEnd: null, onFocus: null, onBlur: null, -} +} satisfies HandlerTargets -export const ATTR_MAP: AttrTargets = { +export const ATTR_MAP: AnyAttrTargets = { // Visual analogs, routed in normalize(): `hidden` inverts into `visible`, // `focusable` is coerced to boolean. hidden: 'visible', @@ -89,10 +89,7 @@ export const ATTR_MAP: AttrTargets = { rowSpan: null, live: null, atomic: null, -} - -const ANY_HANDLERS: AnyHandlerTargets = HANDLER_MAP -const ANY_ATTRS: AnyAttrTargets = ATTR_MAP +} satisfies AttrTargets // Adapters are variadic — 's onChange fires `(index, option)`, not a single arg. diff --git a/packages/shared/bindings/src/index.ts b/packages/shared/bindings/src/index.ts index 584d7ca..446fcf6 100644 --- a/packages/shared/bindings/src/index.ts +++ b/packages/shared/bindings/src/index.ts @@ -258,3 +258,77 @@ export type AttrTargets = Record */ export type AnyHandlerTargets = Record export type AnyAttrTargets = Record + +/** + * The all-dropped ledgers: every vocabulary key declared `null`. A target that + * can't express most of the vocabulary spreads one and overrides what it does + * carry, instead of writing a wall of `null`s. The trade-off is deliberate: + * a spreading target inherits `null` for FUTURE vocabulary keys automatically — + * the compile error for a new key fires here (and in fully-explicit targets), + * so the drop-by-default decision is made once, next to the vocabulary. + */ +export const DROPPED_HANDLERS: HandlerTargets = { + onPress: null, + onPointerEnter: null, + onPointerLeave: null, + onPointerMove: null, + onPointerDown: null, + onPointerUp: null, + onPointerCancel: null, + onFocus: null, + onBlur: null, + onKeyDown: null, + onKeyUp: null, + onValueChange: null, + onContextMenu: null, + onDoublePress: null, + onWheel: null, + onScroll: null, + onScrollEnd: null, +} + +export const DROPPED_ATTRS: AttrTargets = { + id: null, + describedBy: null, + labelledBy: null, + controls: null, + hasPopup: null, + expanded: null, + selected: null, + disabled: null, + hidden: null, + modal: null, + focusable: null, + role: null, + label: null, + checked: null, + pressed: null, + current: null, + busy: null, + invalid: null, + required: null, + readOnly: null, + activeDescendant: null, + errorMessage: null, + owns: null, + valueMin: null, + valueMax: null, + valueNow: null, + valueText: null, + orientation: null, + sort: null, + autoComplete: null, + multiline: null, + multiSelectable: null, + level: null, + posInSet: null, + setSize: null, + colCount: null, + colIndex: null, + colSpan: null, + rowCount: null, + rowIndex: null, + rowSpan: null, + live: null, + atomic: null, +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c904e6..0364c56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: '@dunky.dev/state-machine': specifier: workspace:^ version: link:../core + '@dunky.dev/state-machine-bindings': + specifier: workspace:^ + version: link:../shared/bindings '@dunky.dev/state-machine-utils': specifier: workspace:^ version: link:../shared/utils @@ -141,20 +144,15 @@ importers: react-native: specifier: '*' version: 0.85.0(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6) - devDependencies: - '@dunky.dev/state-machine-bindings': - specifier: workspace:^ - version: link:../shared/bindings packages/opentui: dependencies: - '@dunky.dev/state-machine-utils': - specifier: workspace:^ - version: link:../shared/utils - devDependencies: '@dunky.dev/state-machine-bindings': specifier: workspace:^ version: link:../shared/bindings + '@dunky.dev/state-machine-utils': + specifier: workspace:^ + version: link:../shared/utils packages/react: dependencies: From 0717b18ad640fd66758d688ad8e68a4296eb0d6e Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sat, 15 Aug 2026 01:31:49 +0200 Subject: [PATCH 05/11] docs(changeset): align the example with the final annotation + spread form Co-Authored-By: Claude Fable 5 --- .changeset/bindings-translation-contract.md | 26 ++++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/.changeset/bindings-translation-contract.md b/.changeset/bindings-translation-contract.md index ff017dc..43447af 100644 --- a/.changeset/bindings-translation-contract.md +++ b/.changeset/bindings-translation-contract.md @@ -11,27 +11,31 @@ The bindings package now exports `HandlerKey`/`AttrKey` (derived from `EventBindings`/`AttrBindings`) and the contract types `HandlerTargets`/ `AttrTargets` — `Record`s over the closed vocabulary where every key names the host prop that carries it, or is `null`: a declared drop for a binding the -substrate cannot express. +substrate cannot express. Their string-indexed counterparts +`AnyHandlerTargets`/`AnyAttrTargets` are the exported shape, so a normalize +loop can look the ledger up by arbitrary key. -Each target's normalize declares its maps against the contract: +Each target's normalize declares its maps with both — the annotation for the +loop, the `satisfies` for the contract: ```ts -export const HANDLER_MAP = { +export const HANDLER_MAP: AnyHandlerTargets = { + ...DROPPED_HANDLERS, // hover, keyboard, double-press, wheel: no RN analog onPress: 'onPress', - onWheel: null, // no RN analog — declared, not forgotten - // ...every other vocabulary key, required by the type + onPointerDown: 'onPressIn', + // ...everything this target can express } satisfies HandlerTargets ``` Adding a key to the vocabulary now breaks every target's typecheck until that target decides — mapped or dropped, never silently leaked to the host through the unknown-key passthrough (which still exists, but only for keys outside the -vocabulary, e.g. `data-state`). For targets that can't express most of the -vocabulary, bindings also ships the all-dropped ledgers `DROPPED_HANDLERS`/ -`DROPPED_ATTRS` — spread one and override what the target does carry, instead -of writing a wall of `null`s (the trade-off: a spreading target inherits `null` -for future keys automatically; the compile error fires at the base, next to -the vocabulary). +vocabulary, e.g. `data-state`). The `DROPPED_HANDLERS`/`DROPPED_ATTRS` spread +in the example is the all-dropped base bindings ships for targets that can't +express most of the vocabulary — the trade-off: a spreading target inherits +`null` for future keys automatically, with the compile error firing at the +base, next to the vocabulary. A fully-capable target (react) skips the spread +and writes every key explicitly. The react/native/opentui normalizers adopt the contract with no behavior change: the ad-hoc `HANDLER_DROP`/`ATTR_DROP` sets fold into the `DROPPED_*` From 1af84f8e9e061e32e6dfeb6fae8296dc9c60d997 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sat, 15 Aug 2026 01:33:20 +0200 Subject: [PATCH 06/11] docs(changeset): tighten to the point Co-Authored-By: Claude Fable 5 --- .changeset/bindings-translation-contract.md | 40 +++++++-------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/.changeset/bindings-translation-contract.md b/.changeset/bindings-translation-contract.md index 43447af..a76474b 100644 --- a/.changeset/bindings-translation-contract.md +++ b/.changeset/bindings-translation-contract.md @@ -5,18 +5,16 @@ '@dunky.dev/opentui-state-machine': patch --- -The translation contract: every target accounts for the full vocabulary. +The translation contract: every target must account for every vocabulary key — +mapped, or `null` as a declared drop. Previously the normalize maps were +untyped, so a new binding compiled everywhere and silently leaked to the host; +now it's a compile error in every target until that target decides. -The bindings package now exports `HandlerKey`/`AttrKey` (derived from -`EventBindings`/`AttrBindings`) and the contract types `HandlerTargets`/ -`AttrTargets` — `Record`s over the closed vocabulary where every key names the -host prop that carries it, or is `null`: a declared drop for a binding the -substrate cannot express. Their string-indexed counterparts -`AnyHandlerTargets`/`AnyAttrTargets` are the exported shape, so a normalize -loop can look the ledger up by arbitrary key. - -Each target's normalize declares its maps with both — the annotation for the -loop, the `satisfies` for the contract: +Bindings exports the contract (`HandlerKey`/`AttrKey`, `HandlerTargets`/ +`AttrTargets`, their string-indexed `Any*` counterparts) and the all-dropped +bases `DROPPED_HANDLERS`/`DROPPED_ATTRS` for targets that express little of +the vocabulary (they inherit `null` for future keys; the compile error fires +at the base): ```ts export const HANDLER_MAP: AnyHandlerTargets = { @@ -27,20 +25,6 @@ export const HANDLER_MAP: AnyHandlerTargets = { } satisfies HandlerTargets ``` -Adding a key to the vocabulary now breaks every target's typecheck until that -target decides — mapped or dropped, never silently leaked to the host through -the unknown-key passthrough (which still exists, but only for keys outside the -vocabulary, e.g. `data-state`). The `DROPPED_HANDLERS`/`DROPPED_ATTRS` spread -in the example is the all-dropped base bindings ships for targets that can't -express most of the vocabulary — the trade-off: a spreading target inherits -`null` for future keys automatically, with the compile error firing at the -base, next to the vocabulary. A fully-capable target (react) skips the spread -and writes every key explicitly. - -The react/native/opentui normalizers adopt the contract with no behavior -change: the ad-hoc `HANDLER_DROP`/`ATTR_DROP` sets fold into the `DROPPED_*` -spreads, native's `accessibilityState` fold derives its key set from the -ledger, and the two implicit passthroughs (native `role`, opentui `disabled`) -become explicit renames. A conformance test per target walks its ledger and -asserts every binding lands on its declared target — or, for a `null`, -nowhere at all. +No behavior change in the targets; a conformance test per target walks its +ledger and asserts every binding lands on its declared target — or, for a +`null`, nowhere at all. From 4c83a2936ec7bde3572ba6d785fbbdf1bd706db1 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sat, 15 Aug 2026 01:37:52 +0200 Subject: [PATCH 07/11] =?UTF-8?q?refactor(bindings):=20one=20annotation=20?= =?UTF-8?q?=E2=80=94=20fold=20the=20string=20index=20into=20the=20contract?= =?UTF-8?q?=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HandlerTargets/AttrTargets are now the mapped vocabulary keys intersected with a string index, so a ledger needs a single annotation: exhaustiveness from the mapped keys, loop lookup from the index. The Any* types and the satisfies clauses disappear. Co-Authored-By: Claude Fable 5 --- .changeset/bindings-translation-contract.md | 12 ++++----- packages/native/src/normalize.ts | 18 +++++--------- packages/native/tests/normalize.test.ts | 4 +-- packages/opentui/src/normalize.ts | 18 +++++--------- packages/opentui/tests/normalize.test.ts | 4 +-- packages/react/src/normalize.ts | 15 ++++-------- packages/react/tests/normalize.test.ts | 4 +-- packages/shared/bindings/src/index.ts | 27 +++++++++------------ 8 files changed, 40 insertions(+), 62 deletions(-) diff --git a/.changeset/bindings-translation-contract.md b/.changeset/bindings-translation-contract.md index a76474b..7be261d 100644 --- a/.changeset/bindings-translation-contract.md +++ b/.changeset/bindings-translation-contract.md @@ -11,18 +11,18 @@ untyped, so a new binding compiled everywhere and silently leaked to the host; now it's a compile error in every target until that target decides. Bindings exports the contract (`HandlerKey`/`AttrKey`, `HandlerTargets`/ -`AttrTargets`, their string-indexed `Any*` counterparts) and the all-dropped -bases `DROPPED_HANDLERS`/`DROPPED_ATTRS` for targets that express little of -the vocabulary (they inherit `null` for future keys; the compile error fires -at the base): +`AttrTargets` — exhaustive over the vocabulary, string-indexable for the +loop) and the all-dropped bases `DROPPED_HANDLERS`/`DROPPED_ATTRS` for +targets that express little of the vocabulary (they inherit `null` for +future keys; the compile error fires at the base): ```ts -export const HANDLER_MAP: AnyHandlerTargets = { +export const HANDLER_MAP: HandlerTargets = { ...DROPPED_HANDLERS, // hover, keyboard, double-press, wheel: no RN analog onPress: 'onPress', onPointerDown: 'onPressIn', // ...everything this target can express -} satisfies HandlerTargets +} ``` No behavior change in the targets; a conformance test per target walks its diff --git a/packages/native/src/normalize.ts b/packages/native/src/normalize.ts index a27c3a8..1966058 100644 --- a/packages/native/src/normalize.ts +++ b/packages/native/src/normalize.ts @@ -40,17 +40,11 @@ */ import { DROPPED_ATTRS, DROPPED_HANDLERS } from '@dunky.dev/state-machine-bindings' -import type { - AnyAttrTargets, - AnyHandlerTargets, - AttrTargets, - HandlerTargets, -} from '@dunky.dev/state-machine-bindings' +import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings' // The translation contract: the DROPPED_* spread declares everything a `null` -// drop; the entries after it are what this target can express. `satisfies` -// keeps the overrides typo-checked against the vocabulary. -export const HANDLER_MAP: AnyHandlerTargets = { +// drop; the entries after it are what this target can express. +export const HANDLER_MAP: HandlerTargets = { ...DROPPED_HANDLERS, // hover, keyboard, double-press, wheel: no RN analog onPress: 'onPress', onPointerDown: 'onPressIn', @@ -61,12 +55,12 @@ export const HANDLER_MAP: AnyHandlerTargets = { onContextMenu: 'onLongPress', onScroll: 'onScroll', onScrollEnd: 'onMomentumScrollEnd', -} satisfies HandlerTargets +} // Dropped (via the spread) with intent, not just absence: `describedBy` has no // describe-by-reference slot in RN (no aria-describedby); routing it into the // label slot would misname the element and clobber labelledBy. -export const ATTR_MAP: AnyAttrTargets = { +export const ATTR_MAP: AttrTargets = { ...DROPPED_ATTRS, // Android-only (iOS has no id-reference labelling); the setter takes a // nativeID string or an array (first element wins). @@ -94,7 +88,7 @@ export const ATTR_MAP: AnyAttrTargets = { valueText: 'accessibilityValue', focusable: 'focusable', // value coerced; also sets `accessible` live: 'accessibilityLiveRegion', // value transform: ARIA 'off' → RN 'none' -} satisfies AttrTargets +} // RN's accessibilityState slots — exactly these; anything else is stored and // ignored. Derived from the ledger so the fold can't drift from it. diff --git a/packages/native/tests/normalize.test.ts b/packages/native/tests/normalize.test.ts index 2a0b522..330ba4a 100644 --- a/packages/native/tests/normalize.test.ts +++ b/packages/native/tests/normalize.test.ts @@ -315,7 +315,7 @@ describe('native normalize — vocabulary accounting (the translation contract)' const out = normalize({ [key]: vi.fn() }) // landed: the declared target carries it (null = nothing at all); // leaked: the logical key survived a rename or a drop. - const landed = target === null ? Object.keys(out).length === 0 : target in out + const landed = target == null ? Object.keys(out).length === 0 : target in out const leaked = target !== key && key in out expect({ key, landed, leaked }).toEqual({ key, landed: true, leaked: false }) } @@ -324,7 +324,7 @@ describe('native normalize — vocabulary accounting (the translation contract)' it('routes every attr to its declared target; a null drop leaks nothing', () => { for (const [key, target] of Object.entries(ATTR_MAP)) { const out = normalize({ [key]: key === 'live' ? 'polite' : true }) - const landed = target === null ? Object.keys(out).length === 0 : target in out + const landed = target == null ? Object.keys(out).length === 0 : target in out const leaked = target !== key && key in out expect({ key, landed, leaked }).toEqual({ key, landed: true, leaked: false }) } diff --git a/packages/opentui/src/normalize.ts b/packages/opentui/src/normalize.ts index c7e8930..3e5e8d0 100644 --- a/packages/opentui/src/normalize.ts +++ b/packages/opentui/src/normalize.ts @@ -11,22 +11,16 @@ */ import { DROPPED_ATTRS, DROPPED_HANDLERS } from '@dunky.dev/state-machine-bindings' -import type { - AnyAttrTargets, - AnyHandlerTargets, - AttrTargets, - HandlerTargets, -} from '@dunky.dev/state-machine-bindings' +import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings' // The translation contract: the DROPPED_* spread declares everything a `null` -// drop; the entries after it are what this target can express. `satisfies` -// keeps the overrides typo-checked against the vocabulary. +// drop; the entries after it are what this target can express. // // Dropped (via the spread) with intent, not just absence: `onFocus`/`onBlur` — // OpenTUI signals focus via the `focused` prop; `onScroll`/`onScrollEnd` — // scrollbox has no scroll-position callback; `onKeyUp` — terminals deliver // presses, not up/down. -export const HANDLER_MAP: AnyHandlerTargets = { +export const HANDLER_MAP: HandlerTargets = { ...DROPPED_HANDLERS, onPress: 'onMouseDown', // no synthetic click — a press is a button-down onPointerDown: 'onMouseDown', @@ -37,16 +31,16 @@ export const HANDLER_MAP: AnyHandlerTargets = { onKeyDown: 'onKeyDown', onValueChange: 'onChange', onWheel: 'onMouseScroll', -} satisfies HandlerTargets +} -export const ATTR_MAP: AnyAttrTargets = { +export const ATTR_MAP: AttrTargets = { ...DROPPED_ATTRS, // no ARIA tree in a terminal — the whole vocabulary drops // Visual analogs, routed in normalize(): `hidden` inverts into `visible`, // `focusable` is coerced to boolean. hidden: 'visible', focusable: 'focusable', disabled: 'disabled', -} satisfies AttrTargets +} // Adapters are variadic —