Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1ac5a44
feat(solid): add @dunky.dev/state-machine-solid integration
ivanbanov Jun 23, 2026
2a75bf2
feat(sandbox): add sandbox/solid cmdk demo
ivanbanov Jun 23, 2026
d51e983
Merge branch 'main' into solid
ivanbanov Jun 30, 2026
a5b04fc
Merge branch 'main' into solid
ivanbanov Jul 10, 2026
4fb0777
Merge branch 'main' into solid
ivanbanov Jul 13, 2026
75d04e1
Merge branch 'main' into solid
ivanbanov Aug 17, 2026
d4b395c
refactor(solid): adopt the substrate-prefix name and manifest convent…
ivanbanov Aug 17, 2026
c33432d
feat(solid): adopt the bindings translation contract and current targ…
ivanbanov Aug 17, 2026
4c28cc2
fix(sandbox): harden the solid demo and align the demo copy
ivanbanov Aug 17, 2026
35ad759
docs: add solid to every target enumeration and fix the solid docs page
ivanbanov Aug 17, 2026
e51bea0
fix(react,solid): bind the adapted payloads' preventDefault to its event
ivanbanov Aug 17, 2026
19db904
fix(solid): run ComponentEffect bodies untracked
ivanbanov Aug 17, 2026
5e0b2f0
feat(solid): target Solid 2.0 as the first-class peer
ivanbanov Aug 18, 2026
a9ff675
Update app.tsx
ivanbanov Aug 18, 2026
5dd60b0
Update app.tsx
ivanbanov Aug 18, 2026
4f0f9db
Update README.md
ivanbanov Aug 18, 2026
bed4896
refactor(sandbox): share one stylesheet between the React and Solid d…
ivanbanov Aug 18, 2026
b356d9d
style(sandbox): rejoin the lead line oxfmt wraps
ivanbanov Aug 18, 2026
5710563
refactor(sandbox): move the shared stylesheet into shared/src
ivanbanov Aug 18, 2026
89145fe
fix(solid): stringify boolean aria-* values in normalize
ivanbanov Aug 18, 2026
43d8f93
refactor(solid): shorten the reconcile-workaround comment
ivanbanov Aug 18, 2026
ab3210c
refactor(solid): rebind preventDefault with bind
ivanbanov Aug 18, 2026
31c1c86
feat(solid): target solid-js 2.0.0-rc.1 and drop the reconcile workar…
ivanbanov Aug 19, 2026
d40b8e0
test(solid): close the bridge's behavioral gaps found in review
ivanbanov Aug 19, 2026
fe6ff08
Merge remote-tracking branch 'origin/main' into solid
ivanbanov Aug 19, 2026
72ced27
refactor(dom): extract the shared DOM translation into @dunky.dev/sta…
ivanbanov Aug 21, 2026
af29dd3
Merge branch 'main' into solid
ivanbanov Aug 21, 2026
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
20 changes: 20 additions & 0 deletions .changeset/dom-shared-translation-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@dunky.dev/state-machine-dom': minor
'@dunky.dev/react-state-machine': patch
'@dunky.dev/solid-state-machine': patch
---

Add `@dunky.dev/state-machine-dom` — the DOM half of the bindings translation,
shared by every DOM target. The `aria-*` attribute projection and the payload
adapters (`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` → neutral
payloads, with `preventDefault` bound to its event) were byte-identical in the
React and Solid normalizers; they now live once, in this package, and each
target keeps only what genuinely differs: its handler prop names
(`onChange`/`onDoubleClick` vs `onInput`/`onDblClick`), the `focusable` →
tabindex casing (`tabIndex` vs `tabindex`), and its value serialization
(React passes ARIA booleans through; Solid stringifies them).

No API change for consumers of the React or Solid packages — `normalize`
behaves exactly as before; the shared package becomes a dependency of both.
The motivation is drift-proofing: a payload-adapter fix previously had to be
applied to each DOM target by hand, and had already diverged once.
22 changes: 22 additions & 0 deletions .changeset/payload-prevent-default-bound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@dunky.dev/react-state-machine': patch
'@dunky.dev/solid-state-machine': patch
---

`preventDefault` on the adapted payloads (`ChangePayload`, `WheelPayload`) now
actually works. `normalize()` used to copy the native event's `preventDefault`
onto the payload detached from its event, so the first `connect()` to call
`payload.preventDefault()` would throw `TypeError: Illegal invocation` — native
DOM methods require `this` to be a real `Event`. The payload now carries a
closure bound to the originating event:

```ts
// connect() side — this used to throw, now suppresses the default as promised
onValueChange: payload => {
payload.preventDefault?.()
}
```

Latent until now (no in-repo `connect()` calls it yet), but it is the behavior
the bindings contract promises, so it's fixed in both DOM targets before a
component relies on it.
18 changes: 18 additions & 0 deletions .changeset/solid-effect-deps-untracked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@dunky.dev/solid-state-machine': patch
---

A `ComponentEffect` body now runs untracked, so its authored `deps` list is the
whole re-run contract — identical to the React target's dep array. Previously
the body executed inside the tracking scope, so any prop the effect merely read
became a hidden dependency and re-ran it (cleanup + re-subscribe) on changes to
props it never declared.

```ts
const escape: ComponentEffect<M, Props> = [
(machine, props) => {
void props.onEscapeKeyDown // read, but NOT a dep — no longer re-runs on change
},
['closeOnEscape'], // ONLY this prop re-runs the effect, on every target
]
```
25 changes: 25 additions & 0 deletions .changeset/solid-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@dunky.dev/solid-state-machine': minor
---

Add `@dunky.dev/solid-state-machine` — the Solid bindings target.

A first-class Solid bridge (not a React re-export): `useMachine` mirrors the
connector's snapshot into a Solid `createStore` (via `reconcile`) so reading a
field in JSX is fine-grained, runs the lifecycle through `onSettled`/`onCleanup`,
keeps props fresh with a tracked `setProps` effect, and runs each
`ComponentEffect` as its own dep-tracked `createEffect(compute, apply)`.
`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. The same `connect`
and machine config run unchanged across React, Solid, React Native, and OpenTUI.

Targets Solid 2.0 as a first-class citizen: the peer range is `solid-js`
`^2.0.0-rc.1`. Solid 1.x is not supported — 2.0 removed the surface a 1.x
bridge would stand on (`solid-js/store`, single-argument `createEffect`,
`onMount`) and 1.x lacks the root exports this package imports, so, like the
rest of the Solid ecosystem (router, TanStack, solid-primitives), the majors
are version-split. Two consumer-facing 2.0 behaviors: writes commit on the
microtask queue (call `flush()` in tests before asserting), and JSX comes from
the renderer package (`"jsxImportSource": "@solidjs/web"`, `render` from
`@solidjs/web`).
1 change: 1 addition & 0 deletions ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ record:
hidden: true
|
+-- react -> aria-hidden
+-- solid -> aria-hidden
+-- native -> aria-hidden (RN's web-aligned alias, fanned out per platform)
+-- opentui -> visible={false} (no accessibility tree; the visual analog)
```
Expand Down
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ repo. This file is the canonical entry point: read it first, every time.

This is Dunky's state-machine monorepo: UI behavior authored once as
plain TypeScript state machines (`packages/core`), rendered anywhere
through thin per-substrate targets (`react`, `native`, `opentui`), with
through thin per-substrate targets (`react`, `solid`, `native`, `opentui`), with
a benchmark suite, per-substrate sandboxes, and the docs website
alongside.

Expand All @@ -24,12 +24,12 @@ editing files in that scope — it overrides anything here for that scope

## Scopes

| Scope | Path | What it is |
| --------- | ------------- | ---------------------------------------------------------------------------------- |
| Packages | `packages/**` | The core machine, substrate targets (react, native, opentui), and shared internals |
| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries |
| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification |
| Website | `website/` | The docs site |
| Scope | Path | What it is |
| --------- | ------------- | ----------------------------------------------------------------------------------------- |
| Packages | `packages/**` | The core machine, substrate targets (react, solid, native, opentui), and shared internals |
| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries |
| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification |
| Website | `website/` | The docs site |

Some changes are cross-scope: a change in `core/` may need follow-up in
the targets, sandboxes, and docs — and vice versa. Check what else your
Expand Down
22 changes: 11 additions & 11 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The host
| bridged per target
v
+------------------------------------------------------------------------+
| <target> (react, native, opentui, …) |
| <target> (react, solid, native, opentui, …) |
| Runtime-specific bridge |
| • lifecycle (build + start/stop) • normalize bindings -> props |
| • selector subscription |
Expand Down Expand Up @@ -58,7 +58,7 @@ actions. Nothing in `core/` knows that React or the DOM exists.
substrate-agnostic event and attr vocabulary (`onPress`, `role`, …); `shared/utils`
owns cross-target helpers (mergeProps, composeHandlers, positioning).

**`<target>/`** is the substrate side — `react`, `native`, `opentui`, and any
**`<target>/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any
future renderer. Each target is the runtime bridge for one environment: the
lifecycle bridge, the event normalization, and the selector subscription all
live here.
Expand Down Expand Up @@ -93,12 +93,12 @@ Zag, whose machines read props directly.)

## Project structure

| File / location | What it owns |
| --------------------------- | ------------------------------------------------------------- |
| `packages/core/` | State-machine engine (plain-mutation kernel) |
| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) |
| `packages/shared/utils/` | mergeProps, composeHandlers, positioning, memo |
| `packages/<target>/` | Hook + normalize per substrate (react, native, opentui, …) |
| File / location | What it owns |
| --------------------------- | ----------------------------------------------------------------- |
| `packages/core/` | State-machine engine (plain-mutation kernel) |
| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) |
| `packages/shared/utils/` | mergeProps, composeHandlers, positioning, memo |
| `packages/<target>/` | Hook + normalize per substrate (react, solid, native, opentui, …) |

## The map

Expand All @@ -121,7 +121,7 @@ shared/bindings substrate-agnostic event + attr vocabulary
shared/utils cross-target, cross-component helpers
+-- (composeHandlers, positioning, memo, mergeProps)

<target> one substrate (react, native, opentui, …)
<target> one substrate (react, solid, native, opentui, …)
| runtime, hooks, and props translator
+-- use-machine lifecycle bridge (build + start/stop + useSyncExternalStore)
+-- use-selector fine-grained leaf subscription (O(readers))
Expand All @@ -136,7 +136,7 @@ Three package groups, three jobs:
event + attr vocabulary; `shared/utils` owns agnostic helpers (positioning,
prop merging, memoization).
- **`<target>/`** — _the substrate side_. One folder per renderer
(`react`, `native`, `opentui`). Owns its runtime bridge and its props translator.
(`react`, `solid`, `native`, `opentui`). Owns its runtime bridge and its props translator.

## The machine parts

Expand Down Expand Up @@ -177,7 +177,7 @@ whether it needs props/platform or not:
| 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`, …). |
| **target** | A substrate-specific bridge package and its render environment — `packages/<target>/*` (`react`, `solid`, `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 target's rename map and drop set are vocabulary-typed (`HandlerTargets`/`AttrTargets`, `HandlerKey`/`AttrKey`), so a typo'd or unknown key is a compile error. |
Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ transitions, same accessibility intent. Only the render differs.
| pure behavior — no render |
+---------------+--------------+
| connect() → onPress · role · describedBy
+---------------+---------------+
v v v
+-----------+ +-----------+ +-----------+
| React DOM | | Native | | TUI |
| → onClick | |→ Pressable| | → keypress|
| + aria-* | | + a11y | | + cells |
+-----------+ +-----------+ +-----------+
same behavior, byte-for-byte — only the render differs
+---------------+---------------+---------------+
v v v v
+-----------+ +-----------+ +-----------+ +-----------+
| React DOM | | Solid | | Native | | TUI |
| → onClick | | → onClick | |→ Pressable| | → keypress|
| + aria-* | | + aria-* | | + a11y | | + cells |
+-----------+ +-----------+ +-----------+ +-----------+
same behavior, byte-for-byte — only the render differs
```

> **Status: experimental.** The engine (`packages/core`) is stable and tested. The
Expand Down
2 changes: 2 additions & 0 deletions benchmark/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// don't typecheck), so this is the only thing that catches type errors here.
"extends": "../tsconfig.json",
"compilerOptions": {
// the base sets no `jsx` (it's per-project); the benchmark is React-flavored
"jsx": "react-jsx",
// paths in `extends` resolve relative to THIS file, so redeclare them
"paths": {
"@dunky.dev/state-machine": ["../packages/core/src"],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"website:dev": "pnpm -C website dev",
"website:prod": "pnpm -C website build",
"build": "tsdown",
"typecheck": "tsc -b tsconfig.all.json",
"typecheck": "tsc -b tsconfig/all.json",
"lint": "oxlint .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
Expand Down
21 changes: 21 additions & 0 deletions packages/dom/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Ivan Banov

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
39 changes: 39 additions & 0 deletions packages/dom/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@dunky.dev/state-machine-dom",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dunky-dev/state-machine.git",
"directory": "packages/dom"
},
"files": [
"dist",
"src"
],
"type": "module",
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"access": "public"
},
"scripts": {
"build": "tsdown"
},
"dependencies": {
"@dunky.dev/state-machine-bindings": "workspace:*"
}
}
Loading
Loading