Skip to content

feat(bindings): the translation contract — every target accounts for the full vocabulary - #57

Merged
ivanbanov merged 11 commits into
mainfrom
feat/bindings-translation-contract
Aug 15, 2026
Merged

feat(bindings): the translation contract — every target accounts for the full vocabulary#57
ivanbanov merged 11 commits into
mainfrom
feat/bindings-translation-contract

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

Problem

The vocabulary (EventBindings + AttrBindings, 60 keys) is a closed set, but each target's normalize restates it as untyped data — Record<string, string> maps plus ad-hoc HANDLER_DROP/ATTR_DROP sets. Nothing checks coverage: add a vocabulary key and every target still compiles, with the new binding silently falling through the unknown-key passthrough onto the host (onWheel landing verbatim on a RN View). The native normalizer even states the rule in a comment ("every vocabulary key must be accounted for — an unlisted key would leak") — but only discipline enforced it, and #54 shows this failure class is real.

The contract

shared/bindings owns the vocabulary, so it now also owns the translation shape — types only, nothing added at runtime:

export type HandlerKey = keyof EventBindings
export type AttrKey = keyof AttrBindings
export type HandlerTargets = Record<HandlerKey, string | null>
export type AttrTargets = Record<AttrKey, string | null>

Every target's maps are annotated against it. Record, not Partial: every key is required, excess keys are rejected, and null is a declared drop — the substrate says "I cannot express this" in a reviewable diff instead of a hand-maintained set:

export const HANDLER_MAP: HandlerTargets = {
  onPress: 'onPress',
  onWheel: null, // no RN analog — declared, not forgotten
  // ...every other vocabulary key, required by the type
}

A new vocabulary key now breaks all three targets' typecheck until each decides — mapped or dropped, never leaked. The passthrough survives, but only for keys outside the vocabulary (data-state, consumer extras).

No behavior change

  • HANDLER_DROP/ATTR_DROP sets fold into null entries (native, opentui).
  • Native's accessibilityState fold now derives its key set from the ledger, so routing can't drift from the declaration; the accessibilityValue sub-key table stays.
  • The two implicit passthroughs that rode the leak path on purpose — native role, opentui disabled — become explicit renames with the same output.
  • The bindings dependency is devDependencies + import type only: verified erased from every target's dist (js and d.ts).

Verification

  • A conformance test per target walks its ledger: every binding lands on its declared target — or, for a null, produces nothing at all (the anti-leak assertion, with the offending key in the failure message).
  • 318/318 tests, typecheck, lint, format, build + publint all green.

Follow-ups

  • The vue target (feat(vue): Vue 3 bindings #33) adopts the same annotations once both land — a two-line diff there.
  • A possible next step (discussed, not in scope): a shared web base for the DOM targets — one DOM_EVENT/DOM_ATTRS authority + shared payload adapters, with react/vue keeping only their listener-spelling layer.

🤖 Generated with Claude Code

…the vocabulary

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dunky-state-machine Ready Ready Preview Aug 15, 2026 12:38am

…Targets

The string-indexable view each normalize loop reads through was declared
inline in every target; the pair now lives in bindings next to the
ledger types it widens, and the consts carry the ANY_ prefix so they
can't be mistaken for the ledgers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…d base

A target that can't express most of the vocabulary spreads the base and
overrides what it does carry, instead of writing a wall of nulls. The
drop-by-default decision for future vocabulary keys moves to the base,
declared once next to the vocabulary; `satisfies` keeps the overrides
typo-checked. Native and opentui shed ~70 null lines; react stays fully
explicit. Bindings becomes a runtime dependency of the spreading targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… form

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ontract types

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 <noreply@anthropic.com>
ivanbanov and others added 2 commits August 15, 2026 02:16
…he loop read

HandlerTargets/AttrTargets drop the string index: ledger literals get full
excess-property checking (a typo'd key is a compile error again), and each
normalize loop widens at its read site with a localized
`as Record<string, string | null | undefined>` cast — a miss is `undefined`,
outside the vocabulary, passes through.

Along the way: react's loop gains the null-drop guards its siblings already
had (unreachable today — react maps the full vocabulary — but the contract's
null half now works the day react declares one), and native derives
A11Y_VALUE_KEYS from the ledger like A11Y_STATE_KEYS so the fold can't drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ture

The identical describe block triplicated across the targets moves to
tests/fixtures/ in the bindings package (the contract's home); each target
invokes it with its own normalize + ledgers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mechanism lives on HandlerTargets/AttrTargets in the bindings package
and each file's header; the per-ledger restatements were noise. OpenTUI's
onFocus/onBlur drop rationale moves into its header — the one fact that
lived only in the deleted block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every comment this branch added gets cut to its load-bearing facts; the
mechanics restated from the contract types are gone. A11Y_VALUE_KEYS goes
back to the plain literal — the ledger-derived prefix-strip was clever but
unreadable; the sync constraint is now one comment line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ivanbanov
ivanbanov merged commit 2532b06 into main Aug 15, 2026
8 checks passed
@ivanbanov
ivanbanov deleted the feat/bindings-translation-contract branch August 15, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant