diff --git a/.changeset/bindings-translation-contract.md b/.changeset/bindings-translation-contract.md new file mode 100644 index 0000000..2ff7b6f --- /dev/null +++ b/.changeset/bindings-translation-contract.md @@ -0,0 +1,29 @@ +--- +'@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 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. + +Bindings exports the contract (`HandlerKey`/`AttrKey`, `HandlerTargets`/ +`AttrTargets`) 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: HandlerTargets = { + ...DROPPED_HANDLERS, // hover, keyboard, double-press, wheel: no RN analog + onPress: 'onPress', + onPointerDown: 'onPressIn', + // ...everything this target can express +} +``` + +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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 82600ae..02c0719 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 ledger is typed by the contract (`HandlerTargets`/`AttrTargets`): every key mapped or a `null` drop — a new binding is a compile error until each target decides. | +| **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..7944333 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -36,6 +36,7 @@ "dependencies": { "@dunky.dev/react-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", + "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^" }, "peerDependencies": { diff --git a/packages/native/src/normalize.ts b/packages/native/src/normalize.ts index 4d19a3e..d4682a0 100644 --- a/packages/native/src/normalize.ts +++ b/packages/native/src/normalize.ts @@ -4,8 +4,8 @@ * 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 key must be accounted for — mapped, folded, or a `null` drop; + * the contract types make an unlisted key a compile error, not a leak. * - 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 +38,11 @@ * it (e.g. 'dialog'). */ -const HANDLER_MAP: Record = { +import { DROPPED_ATTRS, DROPPED_HANDLERS } from '@dunky.dev/state-machine-bindings' +import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings' + +export const HANDLER_MAP: HandlerTargets = { + ...DROPPED_HANDLERS, // hover, keyboard, double-press, wheel: no RN analog onPress: 'onPress', onPointerDown: 'onPressIn', onPointerUp: 'onPressOut', @@ -50,19 +54,8 @@ const HANDLER_MAP: Record = { onScrollEnd: 'onMomentumScrollEnd', } -// 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 = { + ...DROPPED_ATTRS, // Android-only (iOS has no id-reference labelling); the setter takes a // nativeID string or an array (first element wins). labelledBy: 'accessibilityLabelledBy', @@ -72,47 +65,31 @@ 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(). + role: 'role', // the web-aligned prop — never the legacy accessibilityRole enum (throws) + + // folded / special channels — normalize() routes these before the 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 — 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']) - -// Logical key → RN's accessibilityValue sub-key (`{ min, max, now, text }`). +// RN's accessibilityState slots, derived from the ledger so the fold can't drift. +const A11Y_STATE_KEYS = new Set( + Object.entries(ATTR_MAP) + .filter(([, target]) => target === 'accessibilityState') + .map(([key]) => key), +) + +// Logical key → accessibilityValue sub-key. Must cover every ledger entry +// that targets 'accessibilityValue', or the fold drifts. const A11Y_VALUE_KEYS: Record = { valueMin: 'min', valueMax: 'max', @@ -159,10 +136,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 = (HANDLER_MAP as Record)[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 +168,8 @@ export function normalize(logical: Bindings): Record { continue } - const attr = ATTR_MAP[key] + const attr = (ATTR_MAP as Record)[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..0e7d00b 100644 --- a/packages/native/tests/normalize.test.ts +++ b/packages/native/tests/normalize.test.ts @@ -13,6 +13,8 @@ */ import { describe, expect, it, vi } from 'vitest' import { normalize } from '@dunky.dev/native-state-machine' +import { ATTR_MAP, HANDLER_MAP } from '../src/normalize' +import { describeVocabularyAccounting } from '../../shared/bindings/tests/fixtures/vocabulary-accounting' describe('native normalize — handlers', () => { it('keeps onPress as-is (RN Pressable.onPress)', () => { @@ -307,3 +309,5 @@ describe('native normalize — realistic slider shape', () => { expect(onValueChange).toHaveBeenCalledWith({ value: 60 }) }) }) + +describeVocabularyAccounting('native', normalize, HANDLER_MAP, ATTR_MAP) diff --git a/packages/opentui/package.json b/packages/opentui/package.json index b9fd912..3459792 100644 --- a/packages/opentui/package.json +++ b/packages/opentui/package.json @@ -34,6 +34,7 @@ "build": "tsdown" }, "dependencies": { + "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^" } } diff --git a/packages/opentui/src/normalize.ts b/packages/opentui/src/normalize.ts index 0972aa9..305036d 100644 --- a/packages/opentui/src/normalize.ts +++ b/packages/opentui/src/normalize.ts @@ -7,10 +7,15 @@ * - `onWheel` → `onMouseScroll`; `onScroll`/`onScrollEnd` dropped (scrollbox has no scroll callback). * - `onValueChange` → `onChange`; adapter handles both bare string and `(index, option)` shapes. * - `onKeyUp` dropped — terminals deliver key presses, not up/down. + * - `onFocus`/`onBlur` dropped — OpenTUI signals focus via the `focused` prop. * - `focusable` passes through as-is. */ -const HANDLER_MAP: Record = { +import { DROPPED_ATTRS, DROPPED_HANDLERS } from '@dunky.dev/state-machine-bindings' +import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings' + +export const HANDLER_MAP: HandlerTargets = { + ...DROPPED_HANDLERS, onPress: 'onMouseDown', // no synthetic click — a press is a button-down onPointerDown: 'onMouseDown', onPointerUp: 'onMouseUp', @@ -22,63 +27,13 @@ const HANDLER_MAP: Record = { onWheel: 'onMouseScroll', } -// 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', -]) +export const ATTR_MAP: AttrTargets = { + ...DROPPED_ATTRS, // no ARIA tree in a terminal — the whole vocabulary drops + // visual analogs — routed in normalize() + hidden: 'visible', + focusable: 'focusable', + disabled: 'disabled', +} // Adapters are variadic —