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
29 changes: 29 additions & 0 deletions .changeset/bindings-translation-contract.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 8 additions & 8 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<target>/*` (`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/<target>/*` (`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`. |
1 change: 1 addition & 0 deletions packages/native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
96 changes: 36 additions & 60 deletions packages/native/src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -38,7 +38,11 @@
* it (e.g. 'dialog').
*/

const HANDLER_MAP: Record<string, string> = {
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',
Expand All @@ -50,19 +54,8 @@ const HANDLER_MAP: Record<string, string> = {
onScrollEnd: 'onMomentumScrollEnd',
}

// No RN analog — stripped.
const HANDLER_DROP = new Set([
'onPointerEnter',
'onPointerLeave',
'onPointerMove',
'onPointerCancel',
'onKeyDown',
'onKeyUp',
'onDoublePress',
'onWheel',
])

const ATTR_MAP: Record<string, string> = {
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',
Expand All @@ -72,47 +65,31 @@ const ATTR_MAP: Record<string, string> = {
// 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<string, string> = {
valueMin: 'min',
valueMax: 'max',
Expand Down Expand Up @@ -159,10 +136,8 @@ export function normalize(logical: Bindings): Record<string, unknown> {
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<string, string | null | undefined>)[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
Expand Down Expand Up @@ -193,7 +168,8 @@ export function normalize(logical: Bindings): Record<string, unknown> {
continue
}

const attr = ATTR_MAP[key]
const attr = (ATTR_MAP as Record<string, string | null | undefined>)[key]
if (attr === null) continue
if (attr) {
out[attr] = value
continue
Expand Down
4 changes: 4 additions & 0 deletions packages/native/tests/normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -307,3 +309,5 @@ describe('native normalize — realistic slider shape', () => {
expect(onValueChange).toHaveBeenCalledWith({ value: 60 })
})
})

describeVocabularyAccounting('native', normalize, HANDLER_MAP, ATTR_MAP)
1 change: 1 addition & 0 deletions packages/opentui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"build": "tsdown"
},
"dependencies": {
"@dunky.dev/state-machine-bindings": "workspace:^",
"@dunky.dev/state-machine-utils": "workspace:^"
}
}
84 changes: 22 additions & 62 deletions packages/opentui/src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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',
Expand All @@ -22,63 +27,13 @@ const HANDLER_MAP: Record<string, string> = {
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 — <select>'s onChange fires `(index, option)`, not a single arg.

Expand Down Expand Up @@ -111,10 +66,8 @@ export function normalize(logical: Bindings): Record<string, unknown> {
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<string, string | null | undefined>)[key]
if (handler === null) continue
if (handler) {
const adapt = PAYLOAD_ADAPTERS[key]
out[handler] = adapt
Expand All @@ -133,6 +86,13 @@ export function normalize(logical: Bindings): Record<string, unknown> {
continue
}

const attr = (ATTR_MAP as Record<string, string | null | undefined>)[key]
if (attr === null) continue
if (attr) {
out[attr] = value
continue
}

out[key] = value
}

Expand Down
4 changes: 4 additions & 0 deletions packages/opentui/tests/normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { normalize } from '@dunky.dev/opentui-state-machine'
import { ATTR_MAP, HANDLER_MAP } from '../src/normalize'
import { describeVocabularyAccounting } from '../../shared/bindings/tests/fixtures/vocabulary-accounting'

describe('opentui normalize — handlers', () => {
it('maps onPress to onMouseDown (no synthetic click in a terminal)', () => {
Expand Down Expand Up @@ -204,3 +206,5 @@ describe('opentui normalize — combined surface (focusable button shape)', () =
})
})
})

describeVocabularyAccounting('opentui', normalize, HANDLER_MAP, ATTR_MAP)
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@dunky.dev/state-machine-utils": "workspace:^"
},
"devDependencies": {
"@dunky.dev/state-machine-bindings": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
Expand Down
11 changes: 7 additions & 4 deletions packages/react/src/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Translate the machine layer's logical surface to React DOM props.
import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings'

const HANDLER_MAP: Record<string, string> = {
export const HANDLER_MAP: HandlerTargets = {
onPress: 'onClick',
onPointerEnter: 'onPointerEnter',
onPointerLeave: 'onPointerLeave',
Expand Down Expand Up @@ -65,7 +66,7 @@ function scrollPayload(e: AnyEvent): unknown {
}
}

const ATTR_MAP: Record<string, string> = {
export const ATTR_MAP: AttrTargets = {
describedBy: 'aria-describedby',
labelledBy: 'aria-labelledby',
controls: 'aria-controls',
Expand Down Expand Up @@ -127,14 +128,16 @@ export function normalize(logical: Bindings): Record<string, unknown> {
for (const [key, value] of Object.entries(logical)) {
if (value === undefined) continue

const handler = HANDLER_MAP[key]
const handler = (HANDLER_MAP as Record<string, string | null | undefined>)[key]
if (handler === null) continue
if (handler) {
const adapt = PAYLOAD_ADAPTERS[key]
out[handler] = adapt ? (e: AnyEvent) => (value as (p: unknown) => void)(adapt(e)) : value
continue
}

const attr = ATTR_MAP[key]
const attr = (ATTR_MAP as Record<string, string | null | undefined>)[key]
if (attr === null) continue
if (attr) {
out[attr] = key === 'focusable' ? (value ? 0 : -1) : value
continue
Expand Down
Loading
Loading