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 integrationΒ #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
ivanbanov added a commit that referenced this pull request Aug 17, 2026
…et APIs

Brings the Solid target up to main's contract changes:

- normalize maps are vocabulary-typed (HandlerTargets/AttrTargets) and
  exported, with keyed lookups β€” a typo or unknown key is now a compile
  error (#57/#60). The header carries the non-mechanical translation
  rationale ACCESSIBILITY.md asks for (focusable -> tabindex 0/-1,
  disabled -> aria-disabled per APG).
- the shared describeVocabularyAccounting fixture runs against the solid
  maps β€” the last target missing the conformance suite.
- ComponentEffects is folded into ComponentEffect; the effects param is a
  plain ComponentEffect[] (#49).
- mergeProps is generic over the consumer's props, cast-free at call
  sites, with the expectTypeOf regression test (#51).
- tests import the published entry (@dunky.dev/solid-state-machine)
  instead of ../src, matching every other target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ivanbanov added a commit that referenced this pull request Aug 21, 2026
* feat(solid): add @dunky.dev/state-machine-solid integration

A first-class Solid bindings target (not a React re-export): useMachine
mirrors the connector snapshot into a createStore via reconcile for
fine-grained updates, runs the lifecycle through onMount/onCleanup, keeps
props fresh with a tracked setProps effect, and runs each ComponentEffect
as its own dep-tracked createEffect. useSelector returns a Solid accessor.
normalize maps the agnostic bindings to Solid DOM props (onInput,
onDblClick, tabindex) and mergeProps applies Solid's class concat +
single-object style merge.

Also split the tsconfig setup into a tsconfig/ folder (base/react/solid/all)
so JSX is a per-project concern, since the repo now has both React and
Solid JSX. Wires Solid into tsdown, the vitest solid project, docs, and a
changeset.

Co-authored-by: Claude <noreply@anthropic.com>

* feat(sandbox): add sandbox/solid cmdk demo

A Solid renderer for the shared ⌘K command-palette machine, mirroring
sandbox/react. Drives the same @sandbox/cmdk-core machine + connect through
the Solid bridge: api is a fine-grained store read directly in JSX, Show/For
for control flow, a createEffect for focus-on-open, and the same cmdkShortcut
ComponentEffect tuple the React sandbox uses. Vite + vite-plugin-solid,
aliasing the workspace TS sources.

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(solid): adopt the substrate-prefix name and manifest conventions

Renames the package to @dunky.dev/solid-state-machine (the convention from
#43) across the manifest, changelog, tsconfig path alias, sandbox, and
changeset. Conforms the manifest to main: ship src/ in the published files
(#53), pin internal workspace deps exact (#59), and add the bindings devDep
the translation contract needs. The changeset now also states the version
policy: solid-js ^1.6 today; Solid 2.0 lands as a separate major once stable.

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

* feat(solid): adopt the bindings translation contract and current target APIs

Brings the Solid target up to main's contract changes:

- normalize maps are vocabulary-typed (HandlerTargets/AttrTargets) and
  exported, with keyed lookups β€” a typo or unknown key is now a compile
  error (#57/#60). The header carries the non-mechanical translation
  rationale ACCESSIBILITY.md asks for (focusable -> tabindex 0/-1,
  disabled -> aria-disabled per APG).
- the shared describeVocabularyAccounting fixture runs against the solid
  maps β€” the last target missing the conformance suite.
- ComponentEffects is folded into ComponentEffect; the effects param is a
  plain ComponentEffect[] (#49).
- mergeProps is generic over the consumer's props, cast-free at call
  sites, with the expectTypeOf regression test (#51).
- tests import the published entry (@dunky.dev/solid-state-machine)
  instead of ../src, matching every other target.

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

* fix(sandbox): harden the solid demo and align the demo copy

- dedupe solid-js in the solid sandbox's vite config: the package alias
  points inside packages/solid, which carries its own solid-js devDep, so
  version skew would load two runtimes and silently kill reactivity.
- clear the input ref on the Show branch's disposal β€” the closed palette
  kept a detached <input> alive until the next open.
- sandbox README now tells the four-substrates story: solid in the tree
  and run instructions, and the lifecycle-hook claim rewritten (three
  targets share the React hook; Solid brings its own bridge).
- react demo copy mentions the Solid version, its page title
  disambiguates (cmdk - React), and a user-visible Palette typo is fixed
  in both apps.

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

* docs: add solid to every target enumeration and fix the solid docs page

Solid existed in no doc a contributor or consumer reads first:

- root README target diagram gains the Solid box; AGENTS.md,
  ARCHITECTURE.md, and the ACCESSIBILITY.md hidden fan-out example now
  enumerate all four targets.
- the solid docs page examples were silently broken: string-shorthand
  transitions the core no-ops, and a bare config object instead of
  setup.infer().createMachine β€” both now mirror the react page. Also:
  Show instead of the React && idiom, the mergeProps import line, the
  stale mapping table replaced with the source link (the 521440e
  convention), cross-target links, and the solid-js version-support note.
- api/effects.mdx no longer states the React-only hooks rule as
  universal and points at the Solid bridge alongside React Native.
- the package README gains the Quick start, the flow diagram, the
  current heading conventions, and the Solid version support section
  (^1.6 now; 2.0 as a separate major once stable, with the migration
  mapped).
- drop a trailing comma in the root tsconfig left by the merge
  resolution.

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

* fix(react,solid): bind the adapted payloads' preventDefault to its event

normalize() copied the native event's preventDefault onto the
ChangePayload/WheelPayload detached, so calling it threw Illegal
invocation. The payload now carries a closure bound to the event.

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

* fix(solid): run ComponentEffect bodies untracked

A prop the effect body merely read became a hidden dependency and
re-ran the effect. The authored deps list is now the whole re-run
contract, matching the React target's dep array.

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

* feat(solid): target Solid 2.0 as the first-class peer

Port the bridge to the 2.0 primitives: root-level createStore/reconcile,
two-phase createEffect(compute, apply), onSettled for the lifecycle, and
@solidjs/web for JSX. Peer range is ^2.0.0-rc.0; 1.x is unsupported (2.0
removed the surface the bridge stands on, so the majors are version-split
like the rest of the Solid ecosystem).

Work around a solid-js 2.0.0-rc.0 bug: reconcile corrupts a store node when
it replaces a function-valued property, halting reactivity on the next
tracked read. The bridge reconciles a view holding the previous function
identities and writes the fresh ones through plain draft assignments, all in
one commit. Regression-tested; remove once fixed upstream.

The solid vitest project and tsconfig now live in packages/solid so the root
workspace carries no Solid dependencies.

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

* Update app.tsx

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>

* Update app.tsx

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>

* Update README.md

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>

* refactor(sandbox): share one stylesheet between the React and Solid demos

Both apps rendered the same command palette with duplicated inline style
objects. Move the shared look into sandbox/shared/styles.css and swap
style={} for class/className β€” each app now supplies only markup, so a
future third DOM sandbox has one file to add, not another copy of the
same styles.

Trims the Solid docs page of comments that just restated the adjacent
code.

* style(sandbox): rejoin the lead line oxfmt wraps

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

* refactor(sandbox): move the shared stylesheet into shared/src

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

* fix(solid): stringify boolean aria-* values in normalize

Solid 2.0 renders a boolean attribute as presence/absence, so
aria-expanded={false} disappeared and aria-modal={true} rendered empty.
ARIA states are literal "true"/"false" tokens β€” serialize them explicitly.
Found by the solid-dialog binding in dunky-dev/ui#44.

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

* refactor(solid): shorten the reconcile-workaround comment

* refactor(solid): rebind preventDefault with bind

Same behavior as the arrow-closure wrapper, one line β€” mirrors the react
normalizer on #64.

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

* feat(solid): target solid-js 2.0.0-rc.1 and drop the reconcile workaround

rc.1 fixes the store corruption when reconcile replaces a function-valued
property, so the function-leaf detour (stableFunctionView /
restoreFunctionLeaves) is gone β€” the bridge reconciles the snapshot
directly. Peer range moves to ^2.0.0-rc.1; the regression test that guarded
the detour now passes against the plain path.

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

* test(solid): close the bridge's behavioral gaps found in review

- useSelector: pin the compute-form seed (a selected function comes back
  by identity, never invoked β€” the createSignal function-arg hazard) and
  that a disposed owner stops evaluating its selector (onCleanup(off)).
- useMachine: pin the stop half of the lifecycle (reactions stop firing
  after unmount), that a ComponentEffect's cleanup runs BEFORE its re-run
  on a dep change, and the negative half of the fine-grained claim (an
  unrelated field change does not wake a reader).
- mergeProps: pin the undefined-consumer early return and the documented
  string-style fall-through to library-wins.

Both regression guards were mutation-verified: reverting the seed to the
value form and dropping onCleanup(off) each fail exactly their new test.

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

* refactor(dom): extract the shared DOM translation into @dunky.dev/state-machine-dom

The aria-* attribute projection and the payload adapters were byte-identical
in the React and Solid normalizers, and had already drifted once (the
preventDefault bind fix landed in one but not the other). They now live once
in packages/dom; each target keeps only what genuinely differs β€” its handler
prop names (onChange/onDoubleClick vs onInput/onDblClick), the focusable β†’
tabindex casing, and its value serialization (React passes ARIA booleans
through, Solid stringifies them).

Payload construction is pinned once in the dom package's tests; the target
suites keep a wiring proof each (normalize wraps the handler with its
adapter) plus the vocabulary accounting. No consumer-facing API change.

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

---------

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
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