From 1ac5a44856ae8c0c7f717435672217962c415cf8 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Tue, 23 Jun 2026 21:58:45 +0200
Subject: [PATCH 01/21] 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
---
.changeset/solid-integration.md | 15 ++
benchmark/tsconfig.json | 2 +
package.json | 4 +-
packages/solid/CHANGELOG.md | 1 +
packages/solid/LICENSE | 21 ++
packages/solid/README.md | 205 +++++++++++++++
packages/solid/package.json | 47 ++++
packages/solid/src/index.ts | 4 +
packages/solid/src/merge-props.ts | 35 +++
packages/solid/src/normalize.ts | 178 +++++++++++++
packages/solid/src/use-machine.ts | 140 +++++++++++
packages/solid/src/use-selector.ts | 56 +++++
packages/solid/tests/merge-props.test.ts | 69 ++++++
packages/solid/tests/normalize.test.ts | 266 ++++++++++++++++++++
packages/solid/tests/use-machine.test.tsx | 275 +++++++++++++++++++++
packages/solid/tests/use-selector.test.tsx | 138 +++++++++++
packages/solid/tsconfig.json | 9 +
pnpm-lock.yaml | 172 ++++++++++++-
tsconfig.all.json | 7 -
tsconfig.json | 27 +-
tsconfig/all.json | 8 +
tsconfig/base.json | 26 ++
tsconfig/react.json | 8 +
tsconfig/solid.json | 18 ++
tsdown.config.ts | 1 +
vitest.config.ts | 39 ++-
website/astro.config.ts | 1 +
website/src/content/docs/libs/solid.mdx | 199 +++++++++++++++
28 files changed, 1933 insertions(+), 38 deletions(-)
create mode 100644 .changeset/solid-integration.md
create mode 100644 packages/solid/CHANGELOG.md
create mode 100644 packages/solid/LICENSE
create mode 100644 packages/solid/README.md
create mode 100644 packages/solid/package.json
create mode 100644 packages/solid/src/index.ts
create mode 100644 packages/solid/src/merge-props.ts
create mode 100644 packages/solid/src/normalize.ts
create mode 100644 packages/solid/src/use-machine.ts
create mode 100644 packages/solid/src/use-selector.ts
create mode 100644 packages/solid/tests/merge-props.test.ts
create mode 100644 packages/solid/tests/normalize.test.ts
create mode 100644 packages/solid/tests/use-machine.test.tsx
create mode 100644 packages/solid/tests/use-selector.test.tsx
create mode 100644 packages/solid/tsconfig.json
delete mode 100644 tsconfig.all.json
create mode 100644 tsconfig/all.json
create mode 100644 tsconfig/base.json
create mode 100644 tsconfig/react.json
create mode 100644 tsconfig/solid.json
create mode 100644 website/src/content/docs/libs/solid.mdx
diff --git a/.changeset/solid-integration.md b/.changeset/solid-integration.md
new file mode 100644
index 0000000..fb5c0fb
--- /dev/null
+++ b/.changeset/solid-integration.md
@@ -0,0 +1,15 @@
+---
+'@dunky.dev/state-machine-solid': minor
+---
+
+Add `@dunky.dev/state-machine-solid` — 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 `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. The same `connect` and machine config run
+unchanged across React, Solid, React Native, and OpenTUI.
diff --git a/benchmark/tsconfig.json b/benchmark/tsconfig.json
index a602cb2..48a9a23 100644
--- a/benchmark/tsconfig.json
+++ b/benchmark/tsconfig.json
@@ -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"],
diff --git a/package.json b/package.json
index 6fe0557..a51ec90 100644
--- a/package.json
+++ b/package.json
@@ -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 .",
@@ -30,8 +30,10 @@
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
"publint": "^0.3.21",
+ "solid-js": "^1.9.13",
"tsdown": "^0.22.2",
"typescript": "^6.0.3",
+ "vite-plugin-solid": "^2.11.12",
"vitest": "^4.1.7"
},
"packageManager": "pnpm@10.20.0"
diff --git a/packages/solid/CHANGELOG.md b/packages/solid/CHANGELOG.md
new file mode 100644
index 0000000..092b0ac
--- /dev/null
+++ b/packages/solid/CHANGELOG.md
@@ -0,0 +1 @@
+# @dunky.dev/state-machine-solid
diff --git a/packages/solid/LICENSE b/packages/solid/LICENSE
new file mode 100644
index 0000000..08a9692
--- /dev/null
+++ b/packages/solid/LICENSE
@@ -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.
diff --git a/packages/solid/README.md b/packages/solid/README.md
new file mode 100644
index 0000000..962fddf
--- /dev/null
+++ b/packages/solid/README.md
@@ -0,0 +1,205 @@
+# `@dunky.dev/state-machine-solid`
+
+The **Solid bindings** for [`@dunky.dev/state-machine`](../core/README.md). The
+core engine is renderer-agnostic; this package is the thin Solid edge that drives
+it: it builds the machine + connector, runs the Solid lifecycle, mirrors the
+connector's snapshot into a fine-grained store, translates the agnostic
+[bindings](../core/README.md#connector--the-view-boundary) vocabulary into DOM
+props, and owns the per-component substrate effects.
+
+This is a **first-class Solid target**, not a re-export of the React bridge.
+React's adapters re-export onto React Native and OpenTUI because those share a
+React reconciler; Solid has its own fine-grained reactivity, so the lifecycle is
+implemented with Solid primitives — `createStore` + `reconcile`, `createEffect`,
+`onMount`/`onCleanup` — and there is no `useSyncExternalStore`. The behavior
+still lives in the core machine and the component's `connect`; this layer only
+adapts them to Solid. Four exports: `useMachine`, `useSelector`, `normalize`,
+`mergeProps`, plus the `ComponentEffect` types.
+
+---
+
+## `useMachine` — the one bridge hook
+
+Every component's generated `useXxxApi` calls this with the agnostic pieces:
+
+```ts
+const { api, machine } = useMachine(
+ tooltipMachineConfig, // (props) => config — config factory, props seed it ONCE
+ connectTooltip, // pure connect(): snapshot → view api
+ tooltipEffects, // the component's substrate effects (ComponentEffect[])
+ props, // the reactive Solid props
+)
+```
+
+It:
+
+- **builds once** — `machine(createConfig(props))` + `connector(service, connect,
+{ ...props })`. A Solid component body runs a single time, so a plain build IS
+ "build once" (no `useMemo` equivalent). The first props seed context and the
+ initial state; later prop changes flow through `setProps`, never a rebuild.
+ > The connector is seeded with a **plain snapshot** (`{ ...props }`), never the
+ > live Solid props proxy. The connector value-dedups in `setProps`; if it held
+ > the proxy it would later compare the proxy against a fresh spread of that same
+ > proxy — whose getters have already updated — find them equal, and never wake.
+- **is fine-grained** — the connector's snapshot is mirrored into a
+ `createStore` via `reconcile` on every connector wake. Reading `api.isOpen` in
+ JSX subscribes to exactly that leaf, so an unrelated field changing won't touch
+ it. `api` is the store proxy — **don't destructure it** (`const { isOpen } =
+api` snapshots the value and drops reactivity); read its fields where you use
+ them.
+- **keeps props fresh** via a tracked effect — `createEffect(() =>
+connection.setProps({ ...props }))`. Solid auto-tracks every prop read in the
+ spread, so it re-runs whenever a consumed prop changes, with no manual dep
+ list. `setProps` value-dedups.
+- **runs the lifecycle** — `service.start()` in `onMount`, `service.stop()` in
+ `onCleanup`. The connector wired its
+ [reactions](../core/README.md#reactions--firing-prop-callbacks-without-the-machine-knowing)
+ to the machine's own `start`/`stop`, so prop-callbacks follow automatically.
+- **runs the component's substrate effects** — one `createEffect` per
+ `ComponentEffect` entry, each reading its named prop deps so it re-subscribes
+ only when one of them changes (see below).
+
+Returns `{ api, machine }`: `api` is the reactive store to spread onto elements;
+`machine` is the running service (also handed to `useSelector`).
+
+---
+
+## `ComponentEffect` — substrate transport, without the boilerplate
+
+Some behavior can't live in the agnostic machine because it needs the **platform
+itself** — a DOM `keydown` listener for Escape, a `ResizeObserver` — and the
+**props** the machine never sees (`closeOnEscape`). That's the component's
+Solid-side _effect_.
+
+Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`) — the
+**same shape as every other target**, so a component's effects are authored once
+and run unchanged on React and Solid:
+
+```ts
+import type { ComponentEffect } from '@dunky.dev/state-machine-solid'
+
+type TooltipEffect = ComponentEffect
+
+/** Escape-to-close (gated by closeOnEscape). */
+const trackEscape: TooltipEffect = [
+ (machine, props) => {
+ if (!props.closeOnEscape) return
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') machine.send({ type: 'escape' })
+ }
+ document.addEventListener('keydown', onKeyDown, true)
+ return () => document.removeEventListener('keydown', onKeyDown, true)
+ },
+ ['closeOnEscape'], // ← re-run only when this prop changes
+]
+
+export const tooltipEffects = [trackEscape]
+```
+
+`useMachine` runs the list — **one `createEffect` per entry**. Each effect READS
+its declared prop deps, so Solid's auto-tracking re-runs it (cleanup → setup)
+only when one of those props actually changes, never on unrelated changes. The
+deps are prop NAMES — typed `(keyof Props)[]`, so a typo is a compile error.
+Reading the deps explicitly (rather than letting the effect body's own reads
+decide) keeps the dependency set driven by the authored `deps` and identical to
+every other target.
+
+> The agnostic _decision_ lives in the core component's resolver; only the
+> _transport_ (the DOM listener) is here. The machine just receives a plain event.
+
+---
+
+## `useSelector` — fine-grained leaf subscription
+
+Returns a Solid **accessor** that updates only when one slice of the machine
+changes:
+
+```ts
+const open = useSelector(machine, () => machine.matches('open'))
+const isHL = useSelector(machine, () => machine.context.highlightedValue === value)
+// read it in JSX:
+```
+
+Backed by a `createSignal` driven by the machine's `select` — a value-deduped
+Selection. `Object.is` by default; **an object/array selection MUST pass a custom
+`isEqual`** so a re-derived equal value doesn't push a change:
+
+```ts
+const pos = useSelector(
+ machine,
+ () => ({ x: machine.context.x, y: machine.context.y }),
+ (a, b) => a.x === b.x && a.y === b.y,
+)
+```
+
+`api` from `useMachine` is already fine-grained, so reach for `useSelector` when
+a leaf wants to track one slice of a machine it doesn't otherwise own — e.g.
+thousands of rows backed by one machine, each waking only for its own value
+(`O(readers)`).
+
+---
+
+## `normalize` — agnostic bindings → DOM props
+
+`connect` returns substrate-agnostic
+[bindings](../core/README.md#connector--the-view-boundary) (`onPress`, `role`).
+`normalize` translates them to DOM/ARIA props as Solid's JSX expects them:
+
+```ts
+const domProps = normalize(api.triggerProps) // { onClick, 'aria-expanded', role, tabindex, ... }
+```
+
+Same vocabulary as the React DOM normalizer, with Solid's JSX conventions:
+
+| Agnostic binding | Solid DOM prop |
+| ---------------- | ------------------------------------- |
+| `onPress` | `onClick` |
+| `onValueChange` | `onInput` (wrapped → `ChangePayload`) |
+| `onDoublePress` | `onDblClick` |
+| `focusable` | `tabindex` (`true → 0`, `false → -1`) |
+
+Pointer/keyboard/focus handlers and the full ARIA attribute set map exactly as in
+the [React DOM normalizer](../react/README.md#normalize--agnostic-bindings--dom-props).
+`undefined` values are dropped; any key not in the map (`class`, `data-*`) passes
+through unchanged. `onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` are wrapped
+so the consumer receives the agnostic payload built from the native DOM event.
+
+---
+
+## `mergeProps` — combine consumer props with the component's props
+
+When a consumer spreads their own props onto the same element the component
+controls, `mergeProps(consumer, library)` merges them the Radix/Ark way, Solid
+flavor:
+
+```ts
+const finalProps = mergeProps(consumerProps, normalize(api.triggerProps))
+```
+
+- **Event handlers are chained, consumer-first** — both run, but if the
+ consumer's handler marks the event `defaultPrevented`, the library handler is
+ skipped (a clean veto).
+- **`class` is concatenated** with a single space and trimmed (Solid uses
+ `class`, not React's `className`).
+- **`style` is merged into ONE object**, library winning on conflicting keys.
+ Solid's `style` is a plain object, not React's array form — so styles merge
+ rather than wrap.
+- **Everything else: library wins** (`id`, `role`, `aria-*`).
+
+> This is **not** Solid's own `mergeProps` from `solid-js` (which merges reactive
+> prop objects). It merges the consumer's props with the component's normalized
+> bindings.
+
+---
+
+## API
+
+| Export | What it is |
+| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `useMachine(config, connect, effects, props)` | the bridge hook — build once + lifecycle + run effects + fine-grained store; returns `{ api, machine }` |
+| `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice; returns a Solid accessor (`O(readers)`) |
+| `normalize(bindings)` | agnostic bindings → Solid DOM/ARIA props |
+| `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `class` concat; `style` object merge) |
+| `ComponentEffect` | `[ (machine, props) => cleanup, (keyof P)[] ]` — one substrate effect + its prop deps |
+| `ComponentEffects` | `ComponentEffect[]` — a component's effect list |
+| `Bindings` | `Record` — the loose shape `normalize` accepts |
diff --git a/packages/solid/package.json b/packages/solid/package.json
new file mode 100644
index 0000000..b711b2d
--- /dev/null
+++ b/packages/solid/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@dunky.dev/state-machine-solid",
+ "version": "0.2.0",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/state-machine.git",
+ "directory": "packages/solid"
+ },
+ "files": [
+ "dist"
+ ],
+ "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": "workspace:^",
+ "@dunky.dev/state-machine-utils": "workspace:^"
+ },
+ "devDependencies": {
+ "@solidjs/testing-library": "^0.8.10",
+ "jsdom": "^29.1.1",
+ "solid-js": "^1.9.13"
+ },
+ "peerDependencies": {
+ "solid-js": "^1.6.0"
+ }
+}
diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts
new file mode 100644
index 0000000..d113d89
--- /dev/null
+++ b/packages/solid/src/index.ts
@@ -0,0 +1,4 @@
+export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine'
+export { useSelector } from './use-selector'
+export { normalize, type Bindings } from './normalize'
+export { mergeProps } from './merge-props'
diff --git a/packages/solid/src/merge-props.ts b/packages/solid/src/merge-props.ts
new file mode 100644
index 0000000..93be260
--- /dev/null
+++ b/packages/solid/src/merge-props.ts
@@ -0,0 +1,35 @@
+import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils'
+
+type AnyProps = Record
+
+/**
+ * Merge consumer props with the component's normalized props, Solid-style.
+ *
+ * Layers Solid's DOM conventions on the substrate-agnostic mergeProps (handler
+ * compose with the `defaultPrevented` veto; everything else library-wins):
+ *
+ * - `class` is concatenated with a space (Solid uses `class`, not React's
+ * `className`).
+ * - `style` is merged into ONE object, library winning on conflicting keys.
+ * Solid's `style` prop is a plain object (or string), NOT React's array form —
+ * so styles merge rather than wrap. (String styles fall through to
+ * library-wins; mixing a string and an object on the same element is a consumer
+ * error Solid itself wouldn't merge either.)
+ */
+export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
+ const merged = baseMergeProps(consumer, library)
+ if (!consumer) return merged
+
+ if (typeof consumer.class === 'string' && typeof library.class === 'string') {
+ merged.class = `${consumer.class} ${library.class}`.trim()
+ }
+ if (isStyleObject(consumer.style) && isStyleObject(library.style)) {
+ merged.style = { ...consumer.style, ...library.style }
+ }
+
+ return merged
+}
+
+function isStyleObject(v: unknown): v is Record {
+ return typeof v === 'object' && v !== null
+}
diff --git a/packages/solid/src/normalize.ts b/packages/solid/src/normalize.ts
new file mode 100644
index 0000000..847d09a
--- /dev/null
+++ b/packages/solid/src/normalize.ts
@@ -0,0 +1,178 @@
+/**
+ * Translate the machine layer's LOGICAL surface to Solid DOM props.
+ *
+ * Logical handler → DOM event prop
+ * Logical attr → DOM/ARIA attr
+ *
+ * Differences from the React DOM normalizer worth flagging:
+ *
+ * - Solid's JSX event props are NATIVE DOM events, not React's synthetic ones.
+ * `onClick` is fine (Solid delegates it), and the event handed to a handler is
+ * a real `MouseEvent`/`PointerEvent`/`KeyboardEvent`/`WheelEvent`, so the
+ * payload adapters below read the native event shape (same field names).
+ * - Solid uses lowercase `tabindex` (the real attribute), not React's camelCase
+ * `tabIndex`. That's the only attr name that differs from the DOM normalizer —
+ * the ARIA attributes (`aria-*`) are written verbatim in Solid JSX, exactly as
+ * here.
+ */
+
+const HANDLER_MAP: Record = {
+ onPress: 'onClick',
+ onPointerEnter: 'onPointerEnter',
+ onPointerLeave: 'onPointerLeave',
+ onPointerMove: 'onPointerMove',
+ onPointerDown: 'onPointerDown',
+ onPointerUp: 'onPointerUp',
+ onPointerCancel: 'onPointerCancel',
+ onFocus: 'onFocus',
+ onBlur: 'onBlur',
+ onKeyDown: 'onKeyDown',
+ onKeyUp: 'onKeyUp',
+ // value-change + secondary/double activation + scroll/wheel. onValueChange/
+ // onWheel/onScroll/onScrollEnd additionally have their argument translated
+ // from the raw DOM event into the agnostic payload (see PAYLOAD_ADAPTERS).
+ onValueChange: 'onInput',
+ onContextMenu: 'onContextMenu',
+ onDoublePress: 'onDblClick',
+ onWheel: 'onWheel',
+ onScroll: 'onScroll',
+ onScrollEnd: 'onScrollEnd',
+}
+
+// Some handlers can't just be renamed: the agnostic payload the component reads
+// (`ChangePayload`/`WheelPayload`/`ScrollPayload`) is a different SHAPE from the
+// native DOM event. For those, normalize wraps the handler so the component
+// receives the agnostic payload — built here from the DOM event — rather than
+// the event itself. (onPress/pointer/keyboard handlers already receive a shape
+// that overlaps PointerPayload/KeyboardPayload, so they pass through unwrapped.)
+
+// DOM WheelEvent.deltaMode (0/1/2) → the neutral WheelPayload unit.
+const WHEEL_UNIT = ['pixel', 'line', 'page'] as const
+
+type AnyEvent = {
+ target?: { value?: unknown; checked?: unknown; type?: string }
+ currentTarget?: Record
+ deltaX?: number
+ deltaY?: number
+ deltaZ?: number
+ deltaMode?: number
+ defaultPrevented?: boolean
+ preventDefault?: () => void
+}
+
+const PAYLOAD_ADAPTERS: Record unknown> = {
+ onValueChange: e => {
+ const t = e?.target
+ // checkbox/radio carry the boolean on `.checked`; everything else on `.value`.
+ const value = t && (t.type === 'checkbox' || t.type === 'radio') ? t.checked : t?.value
+ return { value, defaultPrevented: e?.defaultPrevented, preventDefault: e?.preventDefault }
+ },
+ onWheel: e => ({
+ deltaX: e?.deltaX,
+ deltaY: e?.deltaY,
+ deltaZ: e?.deltaZ,
+ deltaUnit: WHEEL_UNIT[e?.deltaMode ?? 0] ?? 'pixel',
+ defaultPrevented: e?.defaultPrevented,
+ preventDefault: e?.preventDefault,
+ }),
+ onScroll: scrollPayload,
+ onScrollEnd: scrollPayload,
+}
+
+function scrollPayload(e: AnyEvent): unknown {
+ const el = e?.currentTarget ?? {}
+ return {
+ offsetX: el.scrollLeft,
+ offsetY: el.scrollTop,
+ contentWidth: el.scrollWidth,
+ contentHeight: el.scrollHeight,
+ viewportWidth: el.clientWidth,
+ viewportHeight: el.clientHeight,
+ }
+}
+
+const ATTR_MAP: Record = {
+ describedBy: 'aria-describedby',
+ labelledBy: 'aria-labelledby',
+ controls: 'aria-controls',
+ hasPopup: 'aria-haspopup',
+ expanded: 'aria-expanded',
+ selected: 'aria-selected',
+ disabled: 'aria-disabled',
+ hidden: 'aria-hidden',
+ modal: 'aria-modal',
+ focusable: 'tabindex', // value transformed below
+ role: 'role',
+ id: 'id',
+
+ // labeling
+ label: 'aria-label',
+ // widget state (values pass through untransformed — booleans, the 'mixed'
+ // tristate, and the aria-current / aria-invalid enums all serialize as-is)
+ checked: 'aria-checked',
+ pressed: 'aria-pressed',
+ current: 'aria-current',
+ busy: 'aria-busy',
+ invalid: 'aria-invalid',
+ required: 'aria-required',
+ readOnly: 'aria-readonly',
+ // relationships
+ activeDescendant: 'aria-activedescendant',
+ errorMessage: 'aria-errormessage',
+ owns: 'aria-owns',
+ // value / range
+ valueMin: 'aria-valuemin',
+ valueMax: 'aria-valuemax',
+ valueNow: 'aria-valuenow',
+ valueText: 'aria-valuetext',
+ // structure / orientation
+ orientation: 'aria-orientation',
+ sort: 'aria-sort',
+ autoComplete: 'aria-autocomplete',
+ multiline: 'aria-multiline',
+ multiSelectable: 'aria-multiselectable',
+ level: 'aria-level',
+ posInSet: 'aria-posinset',
+ setSize: 'aria-setsize',
+ // grid / table
+ colCount: 'aria-colcount',
+ colIndex: 'aria-colindex',
+ colSpan: 'aria-colspan',
+ rowCount: 'aria-rowcount',
+ rowIndex: 'aria-rowindex',
+ rowSpan: 'aria-rowspan',
+ // live region
+ live: 'aria-live',
+ atomic: 'aria-atomic',
+}
+
+export type Bindings = Record
+
+export function normalize(logical: Bindings): Record {
+ const out: Record = {}
+ for (const [key, value] of Object.entries(logical)) {
+ if (value === undefined) continue
+
+ const handler = HANDLER_MAP[key]
+ if (handler) {
+ const adapt = PAYLOAD_ADAPTERS[key]
+ // Wrap when the agnostic payload differs from the raw DOM event; else the
+ // handler shape already matches (PointerPayload/KeyboardPayload), pass it.
+ out[handler] = adapt ? (e: AnyEvent) => (value as (p: unknown) => void)(adapt(e)) : value
+ continue
+ }
+
+ const attr = ATTR_MAP[key]
+ if (attr) {
+ if (key === 'focusable') {
+ out[attr] = value ? 0 : -1
+ } else {
+ out[attr] = value
+ }
+ continue
+ }
+
+ out[key] = value
+ }
+ return out
+}
diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts
new file mode 100644
index 0000000..b7adf99
--- /dev/null
+++ b/packages/solid/src/use-machine.ts
@@ -0,0 +1,140 @@
+import { createEffect, onCleanup, onMount } from 'solid-js'
+import { createStore, reconcile } from 'solid-js/store'
+import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine'
+
+/**
+ * One substrate-specific effect, declared as a plain setup/teardown function
+ * plus the prop names it depends on:
+ *
+ * const escape: ComponentEffect = [
+ * (machine, props) => { ...addEventListener...; return () => ...remove... },
+ * ['closeOnEscape', 'onEscapeKeyDown'], // re-run when these props change
+ * ]
+ *
+ * The author writes no Solid. The deps are prop NAMES (typed `(keyof Props)[]`,
+ * so typos are compile errors). The bridge runs each effect inside its own
+ * `createEffect` and READS those named props there, so Solid's auto-tracking
+ * re-runs the effect (cleanup → setup) only when one of those props actually
+ * changes — not on unrelated changes, never stale. `machine` is a constant, so
+ * it's not a dependency.
+ *
+ * The tuple shape is identical across every target (React, Solid, …) so a
+ * component's effects are authored ONCE and run unchanged everywhere; only how
+ * the bridge consumes the deps differs (a manual dep array on React, reactive
+ * reads here).
+ */
+export type ComponentEffect = [
+ effect: (machine: Machine, props: Props) => (() => void) | void,
+ deps: (keyof Props)[],
+]
+
+/**
+ * A component's full set of substrate effects — a list, since one component can
+ * have several independent effects with DIFFERENT deps (e.g. an Escape listener
+ * gated by `closeOnEscape` and a Tab trap gated by `focusTrap`). Each gets its
+ * own `createEffect` so only the one whose dep changed re-subscribes.
+ *
+ * Unlike React there's no rules-of-hooks constraint here (a `createEffect` is
+ * not a hook), but keeping it a stable module constant (`export const xEffects =
+ * [...]`) is still the convention — it reads identically across targets and
+ * never rebuilds the effect closures per call.
+ */
+export type ComponentEffects = ComponentEffect[]
+
+/**
+ * The one generic Solid bridge. Every component's generated api.ts calls this
+ * with the agnostic pieces — a config factory and the connect — plus the
+ * component's substrate effects and the (reactive) props:
+ *
+ * useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, props)
+ *
+ * It builds the machine + connector ONCE (a Solid component body runs once, so
+ * no memo is needed — the first render's props seed context + the initial
+ * state), mirrors the connector's snapshot into a fine-grained `createStore`
+ * (via `reconcile`, so only the leaves that actually changed notify their JSX
+ * readers — the whole point of a Solid target), starts the machine on mount and
+ * stops it on cleanup (the connector's reactions follow the machine's lifecycle
+ * automatically), keeps props fresh via a tracked `setProps` effect, and runs
+ * the component's prop-dependent effects (Escape, etc — one `createEffect` each,
+ * re-running when their named prop deps change).
+ *
+ * Returns the connect() api (the reactive store proxy — read `api.isOpen` in
+ * JSX and it updates fine-grained) and the running machine.
+ *
+ * Later prop changes flow through `setProps`, never a rebuild — recreating would
+ * lose state.
+ */
+export function useMachine<
+ State extends string,
+ Context extends object,
+ Event extends { type: string },
+ Props extends object,
+ Api extends object,
+ Computed = Record,
+>(
+ createConfig: (props: Props) => TransitionConfig,
+ connect: Connect,
+ effects: ComponentEffects>, Props>,
+ props: Props,
+): { api: Api; machine: ReturnType> } {
+ // Build machine + connector once. A Solid component body runs a single time,
+ // so a plain build IS "build once" — no useMemo equivalent needed. The props
+ // proxy is reactive; reading it here at build time seeds context + the initial
+ // state from the first values.
+ //
+ // CRITICAL: seed the connector with a PLAIN snapshot (`{ ...props }`), never
+ // the live Solid props proxy. The connector value-dedups in setProps
+ // (shallowEqual), and if it held the proxy it would later compare the proxy
+ // against a fresh spread of that same proxy — whose values have already
+ // updated through the getters — find them equal, and never wake. A frozen
+ // plain copy makes "did a prop change?" a real comparison.
+ const service = machine(createConfig(props))
+ const connection = connector(service, connect, { ...props })
+
+ // Mirror the connector's snapshot into a fine-grained store. `reconcile`
+ // deep-diffs the new snapshot against the store, so reading `api.isOpen` in
+ // JSX subscribes to exactly that leaf — an unrelated field changing won't
+ // touch it. This is what makes the Solid target fine-grained rather than a
+ // coarse "re-render the whole component" bridge. The connector already
+ // memoizes its snapshot (stable identity while clean), and `reconcile` is a
+ // no-op when nothing changed, so a wake that didn't move anything is cheap.
+ const [api, setApi] = createStore(connection.snapshot)
+ const off = connection.subscribe(() => setApi(reconcile(connection.snapshot)))
+ onCleanup(off)
+
+ // Keep consumer props fresh (controlled flags, callbacks). `createEffect`
+ // tracks every prop read inside `connection.setProps(props)` — Solid props are
+ // a reactive proxy — so this re-runs whenever any consumed prop changes, with
+ // no manual dep list. setProps value-dedups, so an unchanged prop set is a
+ // no-op. (The connector was seeded with the initial props at build, so this
+ // only pushes subsequent changes.)
+ createEffect(() => connection.setProps({ ...props }))
+
+ // Lifecycle: boot on mount, tear down on cleanup. The connector wired its
+ // reactions to the machine's own start/stop, so start()/stop() is all the
+ // bridge needs — reactions follow automatically.
+ //
+ // We deliberately do NOT call connection.destroy(): the connector shares this
+ // component's lifetime with the machine (both built above), so they're GC'd
+ // together. destroy() exists for callers that build a connector standalone.
+ onMount(() => service.start())
+ onCleanup(() => service.stop())
+
+ // Component effects — the prop-dependent platform listeners (Escape, etc) the
+ // machine can't own. One `createEffect` per entry: it READS the named prop
+ // deps (so Solid re-runs it when one of them changes), runs the effect, and
+ // registers the returned teardown via onCleanup (run before the next re-run
+ // and on unmount). Reading the deps explicitly — rather than letting the
+ // effect body's own reads decide — keeps the dependency set identical to every
+ // other target, driven by the authored `deps` and nothing else.
+ for (const [fn, deps] of effects) {
+ createEffect(() => {
+ // Touch each declared dep so this effect re-runs when it changes.
+ for (const key of deps) void props[key]
+ const cleanup = fn(service, props)
+ if (cleanup) onCleanup(cleanup)
+ })
+ }
+
+ return { api, machine: service }
+}
diff --git a/packages/solid/src/use-selector.ts b/packages/solid/src/use-selector.ts
new file mode 100644
index 0000000..5984fcc
--- /dev/null
+++ b/packages/solid/src/use-selector.ts
@@ -0,0 +1,56 @@
+import { createSignal, onCleanup, type Accessor } from 'solid-js'
+import type { EqualityFn, Machine } from '@dunky.dev/state-machine'
+
+/**
+ * Fine-grained, selector-based subscription for leaf components.
+ *
+ * The selector reads from the machine directly (`m.context.x`, `m.matches(...)`)
+ * and the returned accessor updates only when the selected VALUE changes — not
+ * on every machine change.
+ *
+ * Returns a Solid Accessor (`() => T`): call it in JSX (`{open()}`) and that JSX
+ * tracks it. The accessor is backed by a `createSignal` driven by the machine's
+ * `select` — a value-deduped Selection. Every machine notify re-evaluates the
+ * selector and value-compares the result (coarse bus + value compare, not
+ * field-level dependency tracking); the signal is written only when the selected
+ * value actually changed, so a change to an UNRELATED field never wakes this
+ * reader. For a leaf list backed by ONE machine per item (the common shape),
+ * each item's accessor wakes only for its own value — the O(readers) property
+ * that makes thousands of leaves cheap.
+ *
+ * const open = useSelector(m, () => m.matches('open'))
+ * const isHL = useSelector(m, () => m.context.highlightedValue === value)
+ *
+ * Equality is `Object.is` by default; pass a custom `isEqual` for object
+ * selections so a re-derived equal object doesn't push a new value.
+ *
+ * The Selection's own dedup AND the signal's equality both use `isEqual`, so an
+ * object selection that returns a fresh `{...}` each evaluation stays stable as
+ * long as `isEqual` deems it unchanged — no spurious updates.
+ */
+export function useSelector<
+ State extends string,
+ Context extends object,
+ T,
+ Event extends { type: string } = { type: string },
+ Computed = Record,
+>(
+ machine: Machine,
+ selector: () => T,
+ isEqual?: EqualityFn,
+): Accessor {
+ const selection = machine.select(selector)
+
+ // Seed the signal with the current selected value. The signal's own equality
+ // is the caller's `isEqual` (falling back to Solid's default Object.is), so
+ // writing an equal value is a no-op — a second line of dedup behind the
+ // Selection's bus-level one.
+ const [value, setValue] = createSignal(selection.value, { equals: isEqual })
+
+ // The Selection fires its listener only when the selected value changes
+ // (Object.is by default, or our `isEqual`); we push that into the signal.
+ const off = selection.subscribe(next => setValue(() => next), isEqual)
+ onCleanup(off)
+
+ return value
+}
diff --git a/packages/solid/tests/merge-props.test.ts b/packages/solid/tests/merge-props.test.ts
new file mode 100644
index 0000000..cec0e17
--- /dev/null
+++ b/packages/solid/tests/merge-props.test.ts
@@ -0,0 +1,69 @@
+/**
+ * Solid mergeProps — consumer + component props, Solid-style. Inherits handler
+ * composition (with the defaultPrevented veto) and library-wins from the agnostic
+ * base; layers Solid's DOM conventions on top: `class` concat (not `className`)
+ * and `style` merged into ONE object (Solid's style is an object, not React's
+ * array form).
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { mergeProps } from '../src'
+
+describe('solid mergeProps', () => {
+ it('inherits handler composition from the agnostic base', () => {
+ const consumer = vi.fn()
+ const library = vi.fn()
+ const merged = mergeProps({ onClick: consumer }, { onClick: library })
+ ;(merged.onClick as (e: unknown) => void)({ defaultPrevented: false })
+ expect(consumer).toHaveBeenCalledOnce()
+ expect(library).toHaveBeenCalledOnce()
+ })
+
+ it('skips the library handler when the consumer prevents default (veto)', () => {
+ const consumer = vi.fn()
+ const library = vi.fn()
+ const merged = mergeProps({ onClick: consumer }, { onClick: library })
+ ;(merged.onClick as (e: unknown) => void)({ defaultPrevented: true })
+ expect(consumer).toHaveBeenCalledOnce()
+ expect(library).not.toHaveBeenCalled()
+ })
+
+ it('inherits library-wins on plain attrs', () => {
+ const out = mergeProps({ id: 'consumer' }, { id: 'lib' })
+ expect(out.id).toBe('lib')
+ })
+
+ it('merges overlapping styles into ONE object — library wins on conflicts', () => {
+ const out = mergeProps(
+ { style: { color: 'red', margin: 0 } },
+ { style: { color: 'blue', padding: 4 } },
+ )
+ expect(out.style).toEqual({ color: 'blue', margin: 0, padding: 4 })
+ })
+
+ it('library style wins when consumer omits style', () => {
+ const libStyle = { color: 'blue' }
+ const out = mergeProps({ id: 'a' }, { style: libStyle })
+ expect(out.style).toBe(libStyle)
+ })
+
+ it('consumer style stays when library omits style', () => {
+ const consumerStyle = { color: 'red' }
+ const out = mergeProps({ style: consumerStyle }, { id: 'a' })
+ expect(out.style).toBe(consumerStyle)
+ })
+
+ it('concatenates overlapping class with a single space', () => {
+ const out = mergeProps({ class: 'a b' }, { class: 'c' })
+ expect(out.class).toBe('a b c')
+ })
+
+ it('trims edge whitespace; inner spacing is preserved verbatim', () => {
+ const out = mergeProps({ class: ' a ' }, { class: ' b ' })
+ expect(out.class).toBe('a b')
+ })
+
+ it('non-string class falls back to library-wins (no concat)', () => {
+ const out = mergeProps({ id: 'a' }, { class: 'x' })
+ expect(out.class).toBe('x')
+ })
+})
diff --git a/packages/solid/tests/normalize.test.ts b/packages/solid/tests/normalize.test.ts
new file mode 100644
index 0000000..c31de38
--- /dev/null
+++ b/packages/solid/tests/normalize.test.ts
@@ -0,0 +1,266 @@
+/**
+ * Solid DOM bindings translator — pure-logic tests (no DOM runtime needed).
+ *
+ * `normalize` maps the core's substrate-agnostic logical surface to real
+ * DOM/ARIA props as Solid's JSX expects them. These tests pin the FULL
+ * vocabulary so every logical binding has an explicit, asserted target. The
+ * differences from the React DOM normalizer are deliberate and pinned:
+ * `onValueChange → onInput`, `onDoublePress → onDblClick`, `focusable →
+ * tabindex` (lowercase).
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { normalize } from '../src'
+
+describe('solid normalize — handlers', () => {
+ it('maps onPress to onClick (the DOM activation event)', () => {
+ const onPress = vi.fn()
+ expect(normalize({ onPress })).toEqual({ onClick: onPress })
+ })
+
+ it('maps the full pointer family to DOM pointer events', () => {
+ const handlers = {
+ onPointerEnter: vi.fn(),
+ onPointerLeave: vi.fn(),
+ onPointerMove: vi.fn(),
+ onPointerDown: vi.fn(),
+ onPointerUp: vi.fn(),
+ onPointerCancel: vi.fn(),
+ }
+ expect(normalize(handlers)).toEqual(handlers)
+ })
+
+ it('passes onFocus / onBlur through', () => {
+ const onFocus = vi.fn()
+ const onBlur = vi.fn()
+ expect(normalize({ onFocus, onBlur })).toEqual({ onFocus, onBlur })
+ })
+
+ it('maps both keyboard handlers (onKeyDown / onKeyUp)', () => {
+ const onKeyDown = vi.fn()
+ const onKeyUp = vi.fn()
+ expect(normalize({ onKeyDown, onKeyUp })).toEqual({ onKeyDown, onKeyUp })
+ })
+})
+
+describe('solid normalize — attributes', () => {
+ it('maps the ARIA reference attrs (describedBy / labelledBy / controls)', () => {
+ expect(normalize({ describedBy: 'd', labelledBy: 'l', controls: 'c' })).toEqual({
+ 'aria-describedby': 'd',
+ 'aria-labelledby': 'l',
+ 'aria-controls': 'c',
+ })
+ })
+
+ it('maps the boolean state attrs to their aria-* equivalents', () => {
+ expect(
+ normalize({ expanded: true, selected: false, disabled: true, hidden: false, modal: true }),
+ ).toEqual({
+ 'aria-expanded': true,
+ 'aria-selected': false,
+ 'aria-disabled': true,
+ 'aria-hidden': false,
+ 'aria-modal': true,
+ })
+ })
+
+ it('maps focusable to tabindex (lowercase; true → 0, false → -1)', () => {
+ expect(normalize({ focusable: true })).toEqual({ tabindex: 0 })
+ expect(normalize({ focusable: false })).toEqual({ tabindex: -1 })
+ })
+
+ it('maps role and id straight through (same name)', () => {
+ expect(normalize({ role: 'tooltip', id: 't:1' })).toEqual({ role: 'tooltip', id: 't:1' })
+ })
+
+ it('passes unknown attrs through unchanged (e.g. data-state, class)', () => {
+ expect(normalize({ 'data-state': 'open', class: 'x' })).toEqual({
+ 'data-state': 'open',
+ class: 'x',
+ })
+ })
+
+ it('skips undefined values', () => {
+ expect(normalize({ role: undefined, id: 'x' })).toEqual({ id: 'x' })
+ })
+})
+
+describe('solid normalize — expanded handler surface', () => {
+ it('maps each value-change / interaction handler to its Solid DOM event prop', () => {
+ const out = normalize({
+ onValueChange: vi.fn(),
+ onContextMenu: vi.fn(),
+ onDoublePress: vi.fn(),
+ onWheel: vi.fn(),
+ onScroll: vi.fn(),
+ onScrollEnd: vi.fn(),
+ })
+ expect(Object.keys(out).sort()).toEqual(
+ ['onContextMenu', 'onDblClick', 'onInput', 'onScroll', 'onScrollEnd', 'onWheel'].sort(),
+ )
+ })
+
+ it('passes onContextMenu / onDoublePress through unwrapped (same payload shape)', () => {
+ const onContextMenu = vi.fn()
+ const onDoublePress = vi.fn()
+ const out = normalize({ onContextMenu, onDoublePress })
+ expect(out.onContextMenu).toBe(onContextMenu)
+ expect(out.onDblClick).toBe(onDoublePress)
+ })
+
+ it('onValueChange receives a ChangePayload built from the DOM event', () => {
+ const onValueChange = vi.fn()
+ const out = normalize({ onValueChange })
+ ;(out.onInput as (e: unknown) => void)({ target: { value: 'hi', type: 'text' } })
+ expect(onValueChange).toHaveBeenCalledWith({
+ value: 'hi',
+ defaultPrevented: undefined,
+ preventDefault: undefined,
+ })
+ ;(out.onInput as (e: unknown) => void)({ target: { checked: true, type: 'checkbox' } })
+ expect(onValueChange).toHaveBeenLastCalledWith(expect.objectContaining({ value: true }))
+ })
+
+ it('onWheel receives a WheelPayload with a neutral deltaUnit (deltaMode → enum)', () => {
+ const onWheel = vi.fn()
+ const out = normalize({ onWheel })
+ ;(out.onWheel as (e: unknown) => void)({ deltaX: 1, deltaY: 2, deltaZ: 0, deltaMode: 1 })
+ expect(onWheel).toHaveBeenCalledWith(
+ expect.objectContaining({ deltaX: 1, deltaY: 2, deltaZ: 0, deltaUnit: 'line' }),
+ )
+ })
+
+ it('onScroll / onScrollEnd receive a neutral ScrollPayload from currentTarget geometry', () => {
+ const onScroll = vi.fn()
+ const out = normalize({ onScroll })
+ ;(out.onScroll as (e: unknown) => void)({
+ currentTarget: {
+ scrollLeft: 5,
+ scrollTop: 50,
+ scrollWidth: 800,
+ scrollHeight: 1200,
+ clientWidth: 400,
+ clientHeight: 600,
+ },
+ })
+ expect(onScroll).toHaveBeenCalledWith({
+ offsetX: 5,
+ offsetY: 50,
+ contentWidth: 800,
+ contentHeight: 1200,
+ viewportWidth: 400,
+ viewportHeight: 600,
+ })
+ })
+})
+
+describe('solid normalize — expanded attribute surface', () => {
+ it('maps widget-state attrs to aria-*, preserving tristate/enum values', () => {
+ expect(
+ normalize({
+ checked: 'mixed',
+ pressed: true,
+ current: 'page',
+ busy: true,
+ invalid: 'spelling',
+ required: true,
+ readOnly: false,
+ }),
+ ).toEqual({
+ 'aria-checked': 'mixed',
+ 'aria-pressed': true,
+ 'aria-current': 'page',
+ 'aria-busy': true,
+ 'aria-invalid': 'spelling',
+ 'aria-required': true,
+ 'aria-readonly': false,
+ })
+ })
+
+ it('maps labeling + relationship attrs', () => {
+ expect(
+ normalize({ label: 'Volume', activeDescendant: 'opt-3', errorMessage: 'e1', owns: 'lb1' }),
+ ).toEqual({
+ 'aria-label': 'Volume',
+ 'aria-activedescendant': 'opt-3',
+ 'aria-errormessage': 'e1',
+ 'aria-owns': 'lb1',
+ })
+ })
+
+ it('maps value/range attrs (slider shape)', () => {
+ expect(normalize({ valueMin: 0, valueMax: 100, valueNow: 70, valueText: '70%' })).toEqual({
+ 'aria-valuemin': 0,
+ 'aria-valuemax': 100,
+ 'aria-valuenow': 70,
+ 'aria-valuetext': '70%',
+ })
+ })
+
+ it('maps structure + grid attrs', () => {
+ expect(
+ normalize({
+ orientation: 'horizontal',
+ sort: 'ascending',
+ autoComplete: 'list',
+ multiline: true,
+ multiSelectable: false,
+ level: 2,
+ posInSet: 3,
+ setSize: 10,
+ colCount: 5,
+ colIndex: 2,
+ colSpan: 1,
+ rowCount: 20,
+ rowIndex: 4,
+ rowSpan: 1,
+ }),
+ ).toEqual({
+ 'aria-orientation': 'horizontal',
+ 'aria-sort': 'ascending',
+ 'aria-autocomplete': 'list',
+ 'aria-multiline': true,
+ 'aria-multiselectable': false,
+ 'aria-level': 2,
+ 'aria-posinset': 3,
+ 'aria-setsize': 10,
+ 'aria-colcount': 5,
+ 'aria-colindex': 2,
+ 'aria-colspan': 1,
+ 'aria-rowcount': 20,
+ 'aria-rowindex': 4,
+ 'aria-rowspan': 1,
+ })
+ })
+
+ it('maps live-region attrs (off passes through as aria-live="off")', () => {
+ expect(normalize({ live: 'off', atomic: true })).toEqual({
+ 'aria-live': 'off',
+ 'aria-atomic': true,
+ })
+ })
+
+ it('translates a realistic slider binding set', () => {
+ const onValueChange = vi.fn()
+ const out = normalize({
+ role: 'slider',
+ orientation: 'horizontal',
+ valueMin: 0,
+ valueMax: 100,
+ valueNow: 40,
+ valueText: '40%',
+ focusable: true,
+ onValueChange,
+ })
+ expect(out).toMatchObject({
+ role: 'slider',
+ 'aria-orientation': 'horizontal',
+ 'aria-valuemin': 0,
+ 'aria-valuemax': 100,
+ 'aria-valuenow': 40,
+ 'aria-valuetext': '40%',
+ tabindex: 0,
+ })
+ ;(out.onInput as (e: unknown) => void)({ target: { value: '50', type: 'range' } })
+ expect(onValueChange).toHaveBeenCalledWith(expect.objectContaining({ value: '50' }))
+ })
+})
diff --git a/packages/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx
new file mode 100644
index 0000000..8930f17
--- /dev/null
+++ b/packages/solid/tests/use-machine.test.tsx
@@ -0,0 +1,275 @@
+// @vitest-environment jsdom
+/**
+ * `useMachine` — the Solid bridge. These tests pin the behavioral contract: build
+ * ONCE, run the machine lifecycle (start on mount / stop on cleanup), keep
+ * consumer props fresh via a tracked setProps effect (value-deduped), run the
+ * connector's reactions across the machine lifecycle, run each ComponentEffect as
+ * its own dep-tracked createEffect, and expose the connect() api as a fine-grained
+ * store — so JSX reading one field updates only when THAT field changes.
+ */
+import { createSignal } from 'solid-js'
+import { render } from '@solidjs/testing-library'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ act as write,
+ machine,
+ makeReaction,
+ type Connect,
+ type TransitionConfig,
+} from '@dunky.dev/state-machine'
+import { type ComponentEffects, useMachine } from '../src'
+
+type ToggleState = 'closed' | 'open'
+interface ToggleCtx {
+ count: number
+}
+type ToggleEvent = { type: 'toggle' }
+
+interface ToggleProps {
+ label?: string
+ onOpenChange?: (open: boolean) => void
+}
+
+const createConfig =
+ (): ((props: ToggleProps) => TransitionConfig) => () => ({
+ initial: 'closed',
+ context: { count: 0 },
+ states: {
+ closed: {
+ on: { toggle: { target: 'open', actions: write($ => ({ count: $.context.count + 1 })) } },
+ },
+ open: { on: { toggle: { target: 'closed' } } },
+ },
+ })
+
+type ToggleApi = {
+ open: boolean
+ label: string | undefined
+ count: number
+ toggle: () => void
+}
+
+const connect: Connect = ({
+ state,
+ context,
+ props,
+ send,
+}) => ({
+ open: state === 'open',
+ label: props.label,
+ count: context.count,
+ toggle: () => send({ type: 'toggle' }),
+})
+
+const reaction = makeReaction()
+connect.reactions = [
+ reaction(
+ m => m.state === 'open',
+ (open, props) => props.onOpenChange?.(open),
+ ),
+]
+
+type ToggleMachine = ReturnType>
+const noEffects: ComponentEffects = []
+
+afterEach(() => vi.clearAllMocks())
+
+describe('useMachine — lifecycle', () => {
+ it('returns { api, machine }: api is the connect() output, machine is the running service', () => {
+ let captured: { api: ToggleApi; machine: ToggleMachine } | undefined
+ function Comp() {
+ const props: ToggleProps = { label: 'hi' }
+ captured = useMachine(createConfig(), connect, noEffects, props)
+ return
{captured.api.label}
+ }
+ render(() => )
+ expect(captured!.api.open).toBe(false)
+ expect(captured!.api.label).toBe('hi')
+ expect(captured!.api.count).toBe(0)
+ expect(typeof captured!.api.toggle).toBe('function')
+ expect(typeof captured!.machine.send).toBe('function')
+ })
+
+ it('starts the machine on mount and stops it on cleanup', () => {
+ let api: ToggleApi | undefined
+ function Comp() {
+ const props: ToggleProps = {}
+ api = useMachine(createConfig(), connect, noEffects, props).api
+ return null
+ }
+ const { unmount } = render(() => )
+ api!.toggle()
+ expect(api!.open).toBe(true)
+ expect(() => unmount()).not.toThrow()
+ })
+
+ it('updates the DOM fine-grained when the read field changes', () => {
+ let api: ToggleApi | undefined
+ function Comp() {
+ const props: ToggleProps = {}
+ api = useMachine(createConfig(), connect, noEffects, props).api
+ return
{api.open ? 'open' : 'closed'}
+ }
+ const { getByTestId } = render(() => )
+ expect(getByTestId('state').textContent).toBe('closed')
+ api!.toggle()
+ expect(getByTestId('state').textContent).toBe('open')
+ expect(api!.count).toBe(1)
+ })
+})
+
+describe('useMachine — fine-grained store', () => {
+ it('a field read updates ONLY when that field changes, not on unrelated changes', () => {
+ // `count` and `open` both live on the api store. A reader of `count` must not
+ // re-run when only an unrelated render happens, and the store reconciles in
+ // place so untouched leaves keep identity. We assert the store proxy reflects
+ // each field independently after a toggle.
+ let api: ToggleApi | undefined
+ const countReads = vi.fn()
+ function Comp() {
+ const props: ToggleProps = {}
+ api = useMachine(createConfig(), connect, noEffects, props).api
+ return (
+ <>
+
+ }
+ render(() => )
+ api!.toggle() // → open, count 1
+ expect(api!.open).toBe(true)
+
+ setLabel('b') // prop change must NOT rebuild/reset state
+ expect(api!.open).toBe(true)
+ expect(api!.count).toBe(1)
+ expect(api!.label).toBe('b') // but the new prop IS reflected
+ })
+})
+
+describe('useMachine — props freshness via setProps', () => {
+ it('flows later prop changes into the snapshot (setProps, not rebuild)', () => {
+ let api: ToggleApi | undefined
+ const [label, setLabel] = createSignal('first')
+ function Comp() {
+ const props: ToggleProps = {
+ get label() {
+ return label()
+ },
+ }
+ api = useMachine(createConfig(), connect, noEffects, props).api
+ return null
+ }
+ render(() => )
+ expect(api!.label).toBe('first')
+ setLabel('second')
+ expect(api!.label).toBe('second')
+ })
+})
+
+describe('useMachine — reactions follow the machine lifecycle', () => {
+ it('fires the connect reaction (onOpenChange) when state flips while mounted', () => {
+ const onOpenChange = vi.fn()
+ let api: ToggleApi | undefined
+ function Comp() {
+ const props: ToggleProps = { onOpenChange }
+ api = useMachine(createConfig(), connect, noEffects, props).api
+ return null
+ }
+ render(() => )
+ expect(onOpenChange).not.toHaveBeenCalled() // not on subscribe
+ api!.toggle()
+ expect(onOpenChange).toHaveBeenCalledWith(true)
+ api!.toggle()
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+})
+
+describe('useMachine — component effects', () => {
+ it('runs each ComponentEffect (setup on mount, cleanup on unmount)', () => {
+ const setup = vi.fn()
+ const cleanup = vi.fn()
+ const effects: ComponentEffects = [[() => (setup(), cleanup), []]]
+ function Comp() {
+ const props: ToggleProps = {}
+ useMachine(createConfig(), connect, effects, props)
+ return null
+ }
+ const { unmount } = render(() => )
+ expect(setup).toHaveBeenCalledOnce()
+ expect(cleanup).not.toHaveBeenCalled()
+ unmount()
+ expect(cleanup).toHaveBeenCalledOnce()
+ })
+
+ it('re-runs an effect ONLY when one of its named prop deps changes', () => {
+ const fn = vi.fn(() => () => {})
+ const effects: ComponentEffects = [[fn, ['label']]]
+ const [label, setLabel] = createSignal('a')
+ const [other, setOther] = createSignal(() => {})
+ function Comp() {
+ const props: ToggleProps = {
+ get label() {
+ return label()
+ },
+ get onOpenChange() {
+ return other()
+ },
+ }
+ useMachine(createConfig(), connect, effects, props)
+ return null
+ }
+ render(() => )
+ expect(fn).toHaveBeenCalledTimes(1)
+
+ setOther(() => () => {}) // non-dep prop changed → no re-run
+ expect(fn).toHaveBeenCalledTimes(1)
+
+ setLabel('b') // dep changed → re-run
+ expect(fn).toHaveBeenCalledTimes(2)
+ })
+
+ it('receives (machine, props) and can read live machine state', () => {
+ let seenOpen: boolean | undefined
+ const effects: ComponentEffects = [
+ [
+ m => {
+ seenOpen = m.matches('open')
+ },
+ [],
+ ],
+ ]
+ function Comp() {
+ const props: ToggleProps = {}
+ useMachine(createConfig(), connect, effects, props)
+ return null
+ }
+ render(() => )
+ expect(seenOpen).toBe(false)
+ })
+})
diff --git a/packages/solid/tests/use-selector.test.tsx b/packages/solid/tests/use-selector.test.tsx
new file mode 100644
index 0000000..4483acc
--- /dev/null
+++ b/packages/solid/tests/use-selector.test.tsx
@@ -0,0 +1,138 @@
+// @vitest-environment jsdom
+/**
+ * `useSelector` — fine-grained leaf subscription. These tests pin the contract:
+ * the selector reads the machine directly, the returned accessor updates ONLY
+ * when the selected value changes (value-deduped, Object.is by default, custom
+ * isEqual for object selections), and a change to one leaf's slice wakes only
+ * that leaf's accessor (the O(readers) property).
+ */
+import { createEffect } from 'solid-js'
+import { render, renderHook } from '@solidjs/testing-library'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act as write, machine, type TransitionConfig } from '@dunky.dev/state-machine'
+import { useSelector } from '../src'
+
+type S = 'idle'
+interface Ctx {
+ a: number
+ b: number
+}
+type Ev = { type: 'incA' } | { type: 'incB' } | { type: 'noop' }
+
+const config: TransitionConfig = {
+ initial: 'idle',
+ context: { a: 0, b: 0 },
+ states: {
+ idle: {
+ on: {
+ // context writes go through setContext (via `act`) so the bus notifies —
+ // a raw in-place `context.a++` mutates the value but never wakes subscribers.
+ incA: write($ => ({ a: $.context.a + 1 })),
+ incB: write($ => ({ b: $.context.b + 1 })),
+ noop: () => {},
+ },
+ },
+ },
+}
+
+function makeMachine() {
+ const m = machine(config)
+ m.start()
+ return m
+}
+
+afterEach(() => vi.clearAllMocks())
+
+describe('useSelector — value-deduped accessor', () => {
+ it('reads the machine directly and reflects the selected value', () => {
+ const m = makeMachine()
+ const { result } = renderHook(() => useSelector(m, () => m.context.a))
+ expect(result()).toBe(0)
+ m.send({ type: 'incA' })
+ expect(result()).toBe(1)
+ })
+
+ it('updates the accessor ONLY when the selected slice changes', () => {
+ const m = makeMachine()
+ const reads = vi.fn()
+ const { result } = renderHook(() => useSelector(m, () => m.context.a))
+ // A tracked reader of the accessor; it re-runs only when the signal changes.
+ createEffect(() => reads(result()))
+ expect(reads).toHaveBeenCalledTimes(1)
+
+ m.send({ type: 'incB' }) // selects `a`, `b` changed → no update
+ expect(reads).toHaveBeenCalledTimes(1)
+
+ m.send({ type: 'noop' }) // nothing changed → no update
+ expect(reads).toHaveBeenCalledTimes(1)
+
+ m.send({ type: 'incA' }) // `a` changed → update
+ expect(reads).toHaveBeenCalledTimes(2)
+ })
+
+ it('defaults to Object.is equality (a re-derived equal value does not update)', () => {
+ const m = makeMachine()
+ const reads = vi.fn()
+ const { result } = renderHook(() => useSelector(m, () => m.context.a > 0))
+ createEffect(() => reads(result()))
+ expect(reads).toHaveBeenCalledTimes(1)
+ m.send({ type: 'incA' }) // false → true (update)
+ expect(reads).toHaveBeenCalledTimes(2)
+ m.send({ type: 'incA' }) // true → true (no update)
+ expect(reads).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('useSelector — custom isEqual for object selections', () => {
+ it('uses the provided isEqual to dedup an object selection', () => {
+ const m = makeMachine()
+ const reads = vi.fn()
+ const { result } = renderHook(() =>
+ useSelector(
+ m,
+ () => ({ a: m.context.a }),
+ (x, y) => x.a === y.a,
+ ),
+ )
+ createEffect(() => reads(result()))
+ expect(reads).toHaveBeenCalledTimes(1)
+
+ m.send({ type: 'incB' }) // selected {a} unchanged → no update
+ expect(reads).toHaveBeenCalledTimes(1)
+
+ m.send({ type: 'incA' }) // {a} changed → update
+ expect(reads).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('useSelector — O(readers): a slice change wakes only its reader', () => {
+ it('updates only the leaf whose selected slice changed', () => {
+ const m = makeMachine()
+ const aRenders = vi.fn()
+ const bRenders = vi.fn()
+ function LeafA() {
+ const a = useSelector(m, () => m.context.a)
+ return {(aRenders(), a())}
+ }
+ function LeafB() {
+ const b = useSelector(m, () => m.context.b)
+ return {(bRenders(), b())}
+ }
+ render(() => (
+ <>
+
+
+ >
+ ))
+ expect(aRenders).toHaveBeenCalledTimes(1)
+ expect(bRenders).toHaveBeenCalledTimes(1)
+
+ m.send({ type: 'incA' }) // only LeafA's slice changed
+ expect(aRenders).toHaveBeenCalledTimes(2)
+ expect(bRenders).toHaveBeenCalledTimes(1)
+
+ m.send({ type: 'incB' }) // only LeafB's slice changed
+ expect(aRenders).toHaveBeenCalledTimes(2)
+ expect(bRenders).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json
new file mode 100644
index 0000000..f00240b
--- /dev/null
+++ b/packages/solid/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ // Thin shim so editors and tsdown find a tsconfig inside the package. The real
+ // Solid project — JSX settings + the typecheck `include`/`exclude` — lives in
+ // tsconfig/solid.json (referenced by tsconfig/all.json). This re-declares
+ // `include` relative to the package dir for tools that resolve from here.
+ "extends": "../../tsconfig/solid.json",
+ "include": ["src", "tests"],
+ "exclude": ["**/node_modules", "**/dist"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2f59d3f..dc60c53 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,12 +35,18 @@ importers:
publint:
specifier: ^0.3.21
version: 0.3.21
+ solid-js:
+ specifier: ^1.9.13
+ version: 1.9.13
tsdown:
specifier: ^0.22.2
version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)
typescript:
specifier: ^6.0.3
version: 6.0.3
+ vite-plugin-solid:
+ specifier: ^2.11.12
+ version: 2.11.12(solid-js@1.9.13)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
vitest:
specifier: ^4.1.7
version: 4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
@@ -180,6 +186,25 @@ importers:
packages/shared/utils: {}
+ packages/solid:
+ dependencies:
+ '@dunky.dev/state-machine':
+ specifier: workspace:^
+ version: link:../core
+ '@dunky.dev/state-machine-utils':
+ specifier: workspace:^
+ version: link:../shared/utils
+ devDependencies:
+ '@solidjs/testing-library':
+ specifier: ^0.8.10
+ version: 0.8.10(solid-js@1.9.13)
+ jsdom:
+ specifier: ^29.1.1
+ version: 29.1.1
+ solid-js:
+ specifier: ^1.9.13
+ version: 1.9.13
+
sandbox/native:
dependencies:
'@dunky.dev/state-machine':
@@ -455,6 +480,10 @@ packages:
resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-module-imports@7.18.6':
+ resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-module-imports@7.29.7':
resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
engines: {node: '>=6.9.0'}
@@ -2780,6 +2809,16 @@ packages:
'@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
+ '@solidjs/testing-library@0.8.10':
+ resolution: {integrity: sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==}
+ engines: {node: '>= 14'}
+ peerDependencies:
+ '@solidjs/router': '>=0.9.0'
+ solid-js: '>=1.0.0'
+ peerDependenciesMeta:
+ '@solidjs/router':
+ optional: true
+
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -3301,6 +3340,11 @@ packages:
resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ babel-plugin-jsx-dom-expressions@0.40.7:
+ resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==}
+ peerDependencies:
+ '@babel/core': ^7.20.12
+
babel-plugin-polyfill-corejs2@0.4.17:
resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==}
peerDependencies:
@@ -3354,6 +3398,15 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
+ babel-preset-solid@1.9.12:
+ resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+ solid-js: ^1.9.12
+ peerDependenciesMeta:
+ solid-js:
+ optional: true
+
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -4294,6 +4347,9 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ html-entities@2.3.3:
+ resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
+
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
@@ -4441,6 +4497,10 @@ packages:
resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==}
engines: {node: '>=4'}
+ is-what@4.1.16:
+ resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
+ engines: {node: '>=12.13'}
+
is-windows@1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
@@ -4781,6 +4841,10 @@ packages:
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
+ merge-anything@5.1.7:
+ resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==}
+ engines: {node: '>=12.13'}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -5836,6 +5900,16 @@ packages:
resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==}
engines: {node: '>=0.10.0'}
+ seroval-plugins@1.5.4:
+ resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
+ seroval@1.5.4:
+ resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==}
+ engines: {node: '>=10'}
+
serve-static@1.16.3:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
@@ -5904,6 +5978,14 @@ packages:
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
+ solid-js@1.9.13:
+ resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==}
+
+ solid-refresh@0.6.3:
+ resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==}
+ peerDependencies:
+ solid-js: ^1.3
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -6419,6 +6501,16 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+ vite-plugin-solid@2.11.12:
+ resolution: {integrity: sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==}
+ peerDependencies:
+ '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.*
+ solid-js: ^1.7.2
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@testing-library/jest-dom':
+ optional: true
+
vite@7.3.5:
resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -6985,6 +7077,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/helper-module-imports@7.18.6':
+ dependencies:
+ '@babel/types': 7.29.7
+
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
@@ -8482,6 +8578,13 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
+ '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@tybys/wasm-util': 0.10.2
+ optional: true
+
'@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)':
dependencies:
'@emnapi/core': 1.11.0
@@ -9129,7 +9232,7 @@ snapshots:
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
- '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@rolldown/binding-wasm32-wasi@1.1.1':
@@ -9286,6 +9389,11 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
+ '@solidjs/testing-library@0.8.10(solid-js@1.9.13)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+ solid-js: 1.9.13
+
'@standard-schema/spec@1.1.0': {}
'@tailwindcss/node@4.3.1':
@@ -9849,6 +9957,15 @@ snapshots:
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.28.0
+ babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.18.6
+ '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
+ '@babel/types': 7.29.7
+ html-entities: 2.3.3
+ parse5: 7.3.0
+
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7):
dependencies:
'@babel/compat-data': 7.29.7
@@ -9950,6 +10067,13 @@ snapshots:
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7)
+ babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.13):
+ dependencies:
+ '@babel/core': 7.29.7
+ babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7)
+ optionalDependencies:
+ solid-js: 1.9.13
+
bail@2.0.2: {}
balanced-match@1.0.2: {}
@@ -11028,6 +11152,8 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ html-entities@2.3.3: {}
+
html-escaper@3.0.3: {}
html-void-elements@3.0.0: {}
@@ -11139,6 +11265,8 @@ snapshots:
dependencies:
better-path-resolve: 1.0.0
+ is-what@4.1.16: {}
+
is-windows@1.0.2: {}
is-wsl@2.2.0:
@@ -11637,6 +11765,10 @@ snapshots:
memoize-one@5.2.1: {}
+ merge-anything@5.1.7:
+ dependencies:
+ is-what: 4.1.16
+
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@@ -13419,6 +13551,12 @@ snapshots:
serialize-error@2.1.0: {}
+ seroval-plugins@1.5.4(seroval@1.5.4):
+ dependencies:
+ seroval: 1.5.4
+
+ seroval@1.5.4: {}
+
serve-static@1.16.3:
dependencies:
encodeurl: 2.0.0
@@ -13518,6 +13656,21 @@ snapshots:
smol-toml@1.6.1: {}
+ solid-js@1.9.13:
+ dependencies:
+ csstype: 3.2.3
+ seroval: 1.5.4
+ seroval-plugins: 1.5.4(seroval@1.5.4)
+
+ solid-refresh@0.6.3(solid-js@1.9.13):
+ dependencies:
+ '@babel/generator': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/types': 7.29.7
+ solid-js: 1.9.13
+ transitivePeerDependencies:
+ - supports-color
+
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -13944,6 +14097,19 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@types/babel__core': 7.20.5
+ babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.13)
+ merge-anything: 5.1.7
+ solid-js: 1.9.13
+ solid-refresh: 0.6.3(solid-js@1.9.13)
+ vite: 8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
+ vitefu: 1.1.3(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
+ transitivePeerDependencies:
+ - supports-color
+
vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
esbuild: 0.27.7
@@ -13997,6 +14163,10 @@ snapshots:
optionalDependencies:
vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
+ vitefu@1.1.3(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
+ optionalDependencies:
+ vite: 8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
+
vitest@4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.7
diff --git a/tsconfig.all.json b/tsconfig.all.json
deleted file mode 100644
index a4dd861..0000000
--- a/tsconfig.all.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- // Aggregator config: `tsc -b` typechecks all real projects (root + benchmark)
- // in one pass. They stay separate because benchmark needs its own `types`
- // (react-dom/jsdom) resolved from benchmark/node_modules. See `typecheck` script.
- "files": [],
- "references": [{ "path": "./tsconfig.json" }, { "path": "./benchmark/tsconfig.json" }]
-}
diff --git a/tsconfig.json b/tsconfig.json
index 7dbe31a..864c273 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,28 +1,3 @@
{
- "compilerOptions": {
- "target": "esnext",
- "allowJs": false,
- "noEmit": true,
- "esModuleInterop": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "lib": ["dom", "dom.iterable", "esnext"],
- "module": "esnext",
- "moduleResolution": "bundler",
- "noImplicitReturns": true,
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "strict": true,
- "paths": {
- "@dunky.dev/state-machine": ["./packages/core/src"],
- "@dunky.dev/state-machine-react": ["./packages/react/src"],
- "@dunky.dev/state-machine-native": ["./packages/native/src"],
- "@dunky.dev/state-machine-opentui": ["./packages/opentui/src"],
- "@dunky.dev/state-machine-utils": ["./packages/shared/utils/src"],
- "@dunky.dev/state-machine-bindings": ["./packages/shared/bindings/src"]
- },
- "types": ["@types/node", "vitest/globals"]
- },
- "include": ["./*.ts", "./packages"],
- "exclude": ["**/node_modules", "**/dist"]
+ "extends": "./tsconfig/base.json",
}
diff --git a/tsconfig/all.json b/tsconfig/all.json
new file mode 100644
index 0000000..99d2fc5
--- /dev/null
+++ b/tsconfig/all.json
@@ -0,0 +1,8 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./react.json" },
+ { "path": "./solid.json" },
+ { "path": "../benchmark/tsconfig.json" }
+ ]
+}
diff --git a/tsconfig/base.json b/tsconfig/base.json
new file mode 100644
index 0000000..eeb21d2
--- /dev/null
+++ b/tsconfig/base.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "allowJs": false,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "noImplicitReturns": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "paths": {
+ "@dunky.dev/state-machine": ["../packages/core/src"],
+ "@dunky.dev/state-machine-react": ["../packages/react/src"],
+ "@dunky.dev/state-machine-solid": ["../packages/solid/src"],
+ "@dunky.dev/state-machine-native": ["../packages/native/src"],
+ "@dunky.dev/state-machine-opentui": ["../packages/opentui/src"],
+ "@dunky.dev/state-machine-utils": ["../packages/shared/utils/src"],
+ "@dunky.dev/state-machine-bindings": ["../packages/shared/bindings/src"]
+ },
+ "types": ["@types/node", "vitest/globals"]
+ }
+}
diff --git a/tsconfig/react.json b/tsconfig/react.json
new file mode 100644
index 0000000..483885c
--- /dev/null
+++ b/tsconfig/react.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx"
+ },
+ "include": ["../*.ts", "../packages"],
+ "exclude": ["../**/node_modules", "../**/dist", "../packages/solid"]
+}
diff --git a/tsconfig/solid.json b/tsconfig/solid.json
new file mode 100644
index 0000000..c5ffdd1
--- /dev/null
+++ b/tsconfig/solid.json
@@ -0,0 +1,18 @@
+{
+ // The Solid project: JSX is `preserve` + `solid-js` as the import source, so
+ // the Solid package and its tests see the Solid JSX namespace (not React's).
+ // Referenced from tsconfig/all.json so `tsc -b` typechecks it alongside the
+ // React project. `paths` is inherited from base (resolved relative to the base
+ // file's location), so no redeclaration is needed.
+ //
+ // `include`/`exclude` are relative to THIS file (tsconfig/), so they reach into
+ // the Solid package. `packages/solid/tsconfig.json` is a thin shim extending
+ // this, kept so editors + tsdown find a tsconfig inside the package.
+ "extends": "./base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js"
+ },
+ "include": ["../packages/solid/src", "../packages/solid/tests"],
+ "exclude": ["../**/node_modules", "../**/dist"]
+}
diff --git a/tsdown.config.ts b/tsdown.config.ts
index 99165b5..ed2218b 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -12,6 +12,7 @@ export default defineConfig({
workspace: [
'packages/core',
'packages/react',
+ 'packages/solid',
'packages/native',
'packages/opentui',
'packages/shared/utils',
diff --git a/vitest.config.ts b/vitest.config.ts
index c3bca12..273b8b2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,9 +1,42 @@
+import solid from 'vite-plugin-solid'
import { defineConfig } from 'vitest/config'
+// Two projects so the Solid tests get their JSX transform without touching the
+// rest of the suite. The default project runs every package the way it always
+// has (node, or jsdom via a per-file `@vitest-environment` comment) and EXCLUDES
+// the Solid tests; the `solid` project owns `packages/solid/tests` with
+// vite-plugin-solid (Solid JSX → reactive runtime) and the `solid-js` dev/browser
+// conditions @solidjs/testing-library needs. Keeping the Solid plugin on its own
+// project is what stops it from rewriting the React `.tsx` tests.
export default defineConfig({
test: {
- globals: false,
- environment: 'node',
- exclude: ['**/node_modules/**', '**/dist/**'],
+ projects: [
+ {
+ test: {
+ name: 'default',
+ globals: false,
+ environment: 'node',
+ include: ['packages/**/tests/**/*.test.{ts,tsx}'],
+ exclude: ['**/node_modules/**', '**/dist/**', 'packages/solid/**'],
+ },
+ },
+ {
+ plugins: [solid()],
+ resolve: {
+ // @solidjs/testing-library + the reactive runtime expect Solid's
+ // dev/browser build conditions.
+ conditions: ['development', 'browser'],
+ },
+ test: {
+ name: 'solid',
+ globals: false,
+ // node by default; the DOM tests opt into jsdom per-file via a
+ // `@vitest-environment jsdom` comment (same convention as the React
+ // package), which also lets knip trace the jsdom devDependency.
+ environment: 'node',
+ include: ['packages/solid/tests/**/*.test.{ts,tsx}'],
+ },
+ },
+ ],
},
})
diff --git a/website/astro.config.ts b/website/astro.config.ts
index 7e05607..df6c9d5 100644
--- a/website/astro.config.ts
+++ b/website/astro.config.ts
@@ -111,6 +111,7 @@ export default defineConfig({
label: 'Integrations',
items: [
{ label: 'React', link: 'libs/react' },
+ { label: 'Solid', link: 'libs/solid' },
{ label: 'React Native', link: 'libs/react-native' },
{ label: 'OpenTUI', link: 'libs/opentui' },
],
diff --git a/website/src/content/docs/libs/solid.mdx b/website/src/content/docs/libs/solid.mdx
new file mode 100644
index 0000000..7463cc3
--- /dev/null
+++ b/website/src/content/docs/libs/solid.mdx
@@ -0,0 +1,199 @@
+---
+title: Solid
+description: Solid bindings for @dunky.dev/state-machine.
+---
+
+import Install from '../../../components/install.astro'
+
+
+
+The Solid package is a thin edge layer. Behavior lives in the core machine and the component's `connect` function; this package only adapts them to Solid: lifecycle, fine-grained reactivity, prop translation, and platform effects. **The machine itself is unchanged** — the same `createDialogConfig` and `connectDialog` that drive React run here.
+
+Unlike React, this is not a `useSyncExternalStore` bridge: the connector's snapshot is mirrored into a Solid **store**, so reading `api.isOpen` in JSX subscribes to exactly that field — only the markup that reads a changed field updates.
+
+## `useMachine`
+
+The one bridge hook. Every component calls it with the four agnostic pieces and gets back the view API as a reactive store:
+
+```tsx
+import { useMachine, normalize } from '@dunky.dev/state-machine-solid'
+import { createDialogConfig, connectDialog, dialogEffects } from './dialog'
+
+type DialogProps = {
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ closeOnEscape?: boolean
+}
+
+function Dialog(props: DialogProps) {
+ const { api } = useMachine(
+ createDialogConfig, // (props) => MachineConfig; seeds context once
+ connectDialog, // pure connect(): snapshot → view api
+ dialogEffects, // ComponentEffect[]: DOM listeners, gated by props
+ props,
+ )
+
+ return (
+ <>
+
+ {api.isOpen &&
Dialog content
}
+ >
+ )
+}
+```
+
+`useMachine` builds the machine and connector **once** (a Solid component body runs a single time, so the first props seed context; later changes flow through `setProps`, not a rebuild), starts on mount, stops on cleanup, and exposes the connect output as a fine-grained store. `api` is the store proxy — read its fields directly in JSX; do **not** destructure it (`const { isOpen } = api` snapshots the value and loses reactivity).
+
+### The three imports
+
+Those three values are where the dialog's behavior actually lives, and none of it is Solid. You write them once, in a `./dialog` module, and they run unchanged on any platform:
+
+```ts
+// dialog.ts: plain functions, no Solid
+import type { Connect } from '@dunky.dev/state-machine'
+
+type State = 'closed' | 'open'
+type Context = { closeOnEscape: boolean }
+type Event = { type: 'open' } | { type: 'close' }
+type Api = {
+ isOpen: boolean
+ triggerProps: object
+ contentProps: object
+}
+
+// createDialogConfig: (props) => machine config. Defines the states
+// ('closed' | 'open'), the events, and seeds context from the first props.
+export const createDialogConfig = (props: DialogProps) => ({
+ initial: props.open ? 'open' : 'closed',
+ context: { closeOnEscape: props.closeOnEscape ?? true },
+ states: {
+ closed: { on: { open: 'open' } },
+ open: { on: { close: 'closed' } },
+ },
+})
+
+// connectDialog: a pure connect() that turns a machine snapshot into the view API
+// your JSX spreads. `isOpen`, `triggerProps`, `contentProps` come from here.
+// The type args are .
+export const connectDialog: Connect = ({
+ state,
+ send,
+}) => ({
+ isOpen: state === 'open',
+ triggerProps: {
+ onPress: () => send({ type: 'open' }),
+ expanded: state === 'open',
+ },
+ contentProps: { role: 'dialog', modal: true },
+})
+
+// dialogEffects: DOM listeners that can't live in the machine. Here, one that
+// closes the dialog on Escape. `onEscapeKey` is a [setup/teardown, deps] tuple;
+// see the ComponentEffect section below for its full body.
+export const dialogEffects = [onEscapeKey]
+```
+
+So `useMachine` is the only Solid-specific piece: `createDialogConfig` is the machine definition, `connectDialog` is the snapshot-to-view-API mapping, and `dialogEffects` are the DOM listeners. This is the **same** `./dialog` module the [React page](/libs/react) imports — only the bridge differs. See [Setup](/api/setup) for configs and [Connector](/api/connector) for how `connect` works in depth.
+
+## `normalize`: bindings → DOM props
+
+`connect` returns substrate-agnostic bindings (`onPress`, `role`, `describedBy`). `normalize` translates them to real DOM/ARIA props as Solid's JSX expects them:
+
+```ts
+normalize(api.triggerProps)
+// { onClick, 'aria-expanded', role, tabindex, ... }
+```
+
+| Binding | DOM prop |
+| ----------------------------------------------- | ------------------------------------------------------------------- |
+| `onPress` | `onClick` |
+| `onValueChange` | `onInput` (payload adapted to `ChangePayload`) |
+| `onDoublePress` | `onDblClick` |
+| `onPointerEnter/Leave/Move/Down/Up/Cancel` | same name |
+| `onFocus` / `onBlur` / `onKeyDown` / `onKeyUp` | same name |
+| `onWheel` / `onScroll` / `onScrollEnd` | same name (payload adapted to `WheelPayload` / `ScrollPayload`) |
+| `describedBy` / `labelledBy` | `aria-describedby` / `aria-labelledby` |
+| `expanded` / `selected` / `disabled` / `hidden` | `aria-expanded` / `aria-selected` / `aria-disabled` / `aria-hidden` |
+| `focusable` | `tabindex` (`true → 0`, `false → -1`) |
+| `role` / `id` | `role` / `id` |
+
+The differences from the [React](/libs/react) DOM normalizer are Solid's JSX conventions: `onValueChange → onInput` (Solid forwards native input events), `onDoublePress → onDblClick`, and `focusable → tabindex` (lowercase, the real attribute). `undefined` values are dropped; unknown keys (`class`, `data-*`) pass through unchanged.
+
+A few handlers whose agnostic payload differs from the raw event (`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd`) are wrapped so the consumer receives the agnostic payload, built from the native DOM event.
+
+## `mergeProps`: consumer + component props
+
+When a consumer spreads their own props onto the same element the component controls:
+
+```tsx
+
+```
+
+- **Event handlers are chained, consumer-first.** If the consumer calls `e.preventDefault()`, the component's handler is skipped — a clean veto.
+- **`class` is concatenated** with a space (Solid uses `class`, not `className`).
+- **`style` is merged into one object**, library winning on conflicting keys. Solid's `style` prop is a plain object, not React's array form — so styles merge rather than wrap.
+- **Everything else: component wins** (`id`, `role`, `aria-*`).
+
+Don't confuse this with Solid's own `mergeProps` from `solid-js` (which merges reactive prop objects). This one merges the consumer's props with the component's normalized bindings, with the handler-veto and `class`/`style` semantics above.
+
+## `useSelector`: fine-grained leaf subscription
+
+For a leaf component that should only react when one slice of the machine changes (useful when a single machine drives many rows and each row should only wake for its own value). It returns a Solid **accessor** — call it in JSX:
+
+```tsx
+import { useSelector } from '@dunky.dev/state-machine-solid'
+
+function Row(props: { machine: RowMachine; value: string }) {
+ const isHighlighted = useSelector(
+ props.machine,
+ () => props.machine.context.highlightedValue === props.value,
+ )
+
+ return
{props.value}
+}
+```
+
+The accessor updates only when the selected value changes — `Object.is` by default. For object selections, pass a custom equality function so a re-derived equal object doesn't push a new value:
+
+```tsx
+const pos = useSelector(
+ props.machine,
+ () => ({ x: props.machine.context.x, y: props.machine.context.y }),
+ (a, b) => a.x === b.x && a.y === b.y,
+)
+```
+
+Reading `api` from `useMachine` is already fine-grained, so `useSelector` is for the case where a leaf wants to track one slice of a machine it doesn't otherwise own — e.g. thousands of items backed by one machine, each waking only for its own value.
+
+## `ComponentEffect`: platform effects
+
+Some behavior can't live in the machine because it touches the DOM or reads props the machine never sees. Declare each as a `[setup/teardown, depPropNames]` tuple; `useMachine` runs one `createEffect` per entry, re-running it (cleanup → setup) when one of its named prop deps changes:
+
+```ts
+import type { ComponentEffect } from '@dunky.dev/state-machine-solid'
+import type { DialogMachine, DialogProps } from './dialog'
+
+type Effect = ComponentEffect
+
+const onEscapeKey: Effect = [
+ (machine, props) => {
+ if (!props.closeOnEscape) return
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') machine.send({ type: 'close' })
+ }
+ document.addEventListener('keydown', handler)
+ return () => document.removeEventListener('keydown', handler)
+ },
+ ['closeOnEscape'], // re-run only when this prop changes
+]
+
+export const dialogEffects = [onEscapeKey]
+```
+
+The tuple shape is **identical to every other target** — the same `./dialog` effects run on React and Solid. The deps are prop NAMES (typed `(keyof Props)[]`, so a typo is a compile error); the Solid bridge reads exactly those props inside the effect's `createEffect`, so Solid's auto-tracking re-subscribes only when one of them actually changes. The machine only receives `send({ type: 'close' })`; it has no idea a keyboard event exists.
+
+See [Effects](/api/effects) for the full mental model of where each type of effect lives.
+
+## Same machine, every target
+
+`createDialogConfig` and `connectDialog` are written once and shared across React, Solid, the terminal, and React Native. The machine never changes; only the bridge (`useMachine`), `normalize`, and the lifecycle wiring differ between targets.
From 2a75bf27e7a85fa2b7f3c14775c4475c3729dd10 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Tue, 23 Jun 2026 22:02:21 +0200
Subject: [PATCH 02/21] feat(sandbox): add sandbox/solid cmdk demo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
pnpm-lock.yaml | 45 +++++++
sandbox/solid/index.html | 12 ++
sandbox/solid/package.json | 23 ++++
sandbox/solid/src/app.tsx | 57 +++++++++
sandbox/solid/src/command-palette.tsx | 164 ++++++++++++++++++++++++++
sandbox/solid/src/main.tsx | 7 ++
sandbox/solid/tsconfig.json | 9 ++
sandbox/solid/vite.config.ts | 19 +++
8 files changed, 336 insertions(+)
create mode 100644 sandbox/solid/index.html
create mode 100644 sandbox/solid/package.json
create mode 100644 sandbox/solid/src/app.tsx
create mode 100644 sandbox/solid/src/command-palette.tsx
create mode 100644 sandbox/solid/src/main.tsx
create mode 100644 sandbox/solid/tsconfig.json
create mode 100644 sandbox/solid/vite.config.ts
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dc60c53..629cf55 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -328,6 +328,34 @@ importers:
specifier: workspace:^
version: link:../../packages/shared/bindings
+ sandbox/solid:
+ dependencies:
+ '@dunky.dev/state-machine':
+ specifier: workspace:^
+ version: link:../../packages/core
+ '@dunky.dev/state-machine-bindings':
+ specifier: workspace:^
+ version: link:../../packages/shared/bindings
+ '@dunky.dev/state-machine-solid':
+ specifier: workspace:^
+ version: link:../../packages/solid
+ '@dunky.dev/state-machine-utils':
+ specifier: workspace:^
+ version: link:../../packages/shared/utils
+ '@sandbox/cmdk-core':
+ specifier: workspace:^
+ version: link:../shared
+ solid-js:
+ specifier: ^1.9.13
+ version: 1.9.13
+ devDependencies:
+ vite:
+ specifier: ^8.0.14
+ version: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
+ vite-plugin-solid:
+ specifier: ^2.11.12
+ version: 2.11.12(solid-js@1.9.13)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
+
website:
dependencies:
'@astrojs/starlight':
@@ -14110,6 +14138,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ vite-plugin-solid@2.11.12(solid-js@1.9.13)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@types/babel__core': 7.20.5
+ babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.13)
+ merge-anything: 5.1.7
+ solid-js: 1.9.13
+ solid-refresh: 0.6.3(solid-js@1.9.13)
+ vite: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
+ vitefu: 1.1.3(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
+ transitivePeerDependencies:
+ - supports-color
+
vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
esbuild: 0.27.7
@@ -14167,6 +14208,10 @@ snapshots:
optionalDependencies:
vite: 8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
+ vitefu@1.1.3(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
+ optionalDependencies:
+ vite: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
+
vitest@4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.7
diff --git a/sandbox/solid/index.html b/sandbox/solid/index.html
new file mode 100644
index 0000000..1f700fa
--- /dev/null
+++ b/sandbox/solid/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ cmdk · Solid
+
+
+
+
+
+
diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json
new file mode 100644
index 0000000..06834b0
--- /dev/null
+++ b/sandbox/solid/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@sandbox/cmdk-solid",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@dunky.dev/state-machine": "workspace:^",
+ "@dunky.dev/state-machine-bindings": "workspace:^",
+ "@dunky.dev/state-machine-solid": "workspace:^",
+ "@dunky.dev/state-machine-utils": "workspace:^",
+ "@sandbox/cmdk-core": "workspace:^",
+ "solid-js": "^1.9.13"
+ },
+ "devDependencies": {
+ "vite": "^8.0.14",
+ "vite-plugin-solid": "^2.11.12"
+ }
+}
diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx
new file mode 100644
index 0000000..d74e46e
--- /dev/null
+++ b/sandbox/solid/src/app.tsx
@@ -0,0 +1,57 @@
+import { createSignal } from 'solid-js'
+import type { JSX } from 'solid-js'
+import { DEMO_COMMANDS } from '@sandbox/cmdk-core'
+import { CommandPalette } from './command-palette'
+
+export function App() {
+ const [last, setLast] = createSignal('—')
+
+ return (
+
+
+ )
+}
+
+const styles: Record = {
+ trigger: {
+ display: 'flex',
+ 'justify-content': 'space-between',
+ 'min-width': '300px',
+ 'align-items': 'center',
+ gap: '8px',
+ padding: '10px 14px',
+ 'font-size': '14px',
+ color: '#5b6172',
+ background: '#fff',
+ border: '1px solid rgba(13,15,22,0.12)',
+ 'border-radius': '10px',
+ cursor: 'pointer',
+ },
+ kbd: {
+ 'font-family': 'ui-monospace, monospace',
+ 'font-size': '11px',
+ color: '#8990a0',
+ background: 'rgba(13,15,22,0.05)',
+ border: '1px solid rgba(13,15,22,0.08)',
+ 'border-radius': '6px',
+ padding: '2px 6px',
+ },
+ backdrop: {
+ position: 'fixed',
+ inset: 0,
+ background: 'rgba(13,15,22,0.35)',
+ display: 'flex',
+ 'justify-content': 'center',
+ 'align-items': 'flex-start',
+ 'padding-top': '14vh',
+ },
+ panel: {
+ width: 'min(560px, 92vw)',
+ background: '#fff',
+ 'border-radius': '14px',
+ 'box-shadow': '0 24px 64px rgba(13,15,22,0.28)',
+ overflow: 'hidden',
+ },
+ input: {
+ width: '100%',
+ 'min-width': '300px',
+ 'box-sizing': 'border-box',
+ padding: '18px 20px',
+ 'font-size': '16px',
+ border: 'none',
+ 'border-bottom': '1px solid rgba(13,15,22,0.08)',
+ outline: 'none',
+ 'border-top-left-radius': '8px',
+ 'border-top-right-radius': '8px',
+ },
+ list: {
+ 'list-style': 'none',
+ margin: 0,
+ padding: '8px',
+ 'max-height': '320px',
+ 'overflow-y': 'auto',
+ },
+ item: {
+ display: 'flex',
+ 'justify-content': 'space-between',
+ 'align-items': 'center',
+ padding: '10px 12px',
+ 'border-radius': '8px',
+ 'font-size': '14px',
+ color: '#1c1e26',
+ cursor: 'pointer',
+ },
+ itemActive: { background: 'rgba(91,115,255,0.12)', color: '#3142c4' },
+ empty: { padding: '16px 12px', color: '#8990a0', 'font-size': '14px' },
+}
diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx
new file mode 100644
index 0000000..6d7e41d
--- /dev/null
+++ b/sandbox/solid/src/main.tsx
@@ -0,0 +1,7 @@
+import { render } from 'solid-js/web'
+import { App } from './app'
+
+const root = document.getElementById('root')
+if (!root) throw new Error('missing #root')
+
+render(() => , root)
diff --git a/sandbox/solid/tsconfig.json b/sandbox/solid/tsconfig.json
new file mode 100644
index 0000000..e437a25
--- /dev/null
+++ b/sandbox/solid/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "types": ["node"]
+ },
+ "include": ["src/**/*", "vite.config.ts"]
+}
diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts
new file mode 100644
index 0000000..30b1ab2
--- /dev/null
+++ b/sandbox/solid/vite.config.ts
@@ -0,0 +1,19 @@
+import { resolve } from 'node:path'
+import solid from 'vite-plugin-solid'
+import { defineConfig } from 'vite'
+
+// The @dunky.dev/* packages and the shared cmdk core all point `main` at their TS
+// `src/index.ts` (no build step). Alias each to its source so Vite transpiles them
+// directly — the whole point of the sandbox is to run the workspace source live.
+export default defineConfig({
+ plugins: [solid()],
+ resolve: {
+ alias: {
+ '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'),
+ '@dunky.dev/state-machine-solid': resolve(__dirname, '../../packages/solid/src'),
+ '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'),
+ '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'),
+ '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'),
+ },
+ },
+})
From d4b395ced11c0cc2c2ced43d5251c8fbc30f1d70 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Mon, 17 Aug 2026 23:39:38 +0200
Subject: [PATCH 03/21] 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
---
.changeset/solid-integration.md | 11 +++++++++--
packages/solid/CHANGELOG.md | 2 +-
packages/solid/package.json | 10 ++++++----
pnpm-lock.yaml | 13 ++++++++-----
sandbox/solid/package.json | 2 +-
sandbox/solid/src/command-palette.tsx | 2 +-
sandbox/solid/vite.config.ts | 2 +-
tsconfig/base.json | 2 +-
8 files changed, 28 insertions(+), 16 deletions(-)
diff --git a/.changeset/solid-integration.md b/.changeset/solid-integration.md
index fb5c0fb..799d7f7 100644
--- a/.changeset/solid-integration.md
+++ b/.changeset/solid-integration.md
@@ -1,8 +1,8 @@
---
-'@dunky.dev/state-machine-solid': minor
+'@dunky.dev/solid-state-machine': minor
---
-Add `@dunky.dev/state-machine-solid` — the Solid bindings target.
+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
@@ -13,3 +13,10 @@ 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.
+
+Supports `solid-js` `^1.6` (the 1.x line). Solid 2.0 — a release candidate as
+of August 2026 — removes the exact surface this bridge stands on
+(`solid-js/store`, single-argument `createEffect`, `onMount`, the 1.x
+`reconcile` calling convention), so, like the rest of the Solid ecosystem
+(router, TanStack, solid-primitives), 2.0 support will ship as a separate major
+once 2.0 is stable rather than as a dual-version range.
diff --git a/packages/solid/CHANGELOG.md b/packages/solid/CHANGELOG.md
index 092b0ac..08a5d31 100644
--- a/packages/solid/CHANGELOG.md
+++ b/packages/solid/CHANGELOG.md
@@ -1 +1 @@
-# @dunky.dev/state-machine-solid
+# @dunky.dev/solid-state-machine
diff --git a/packages/solid/package.json b/packages/solid/package.json
index b711b2d..76a612b 100644
--- a/packages/solid/package.json
+++ b/packages/solid/package.json
@@ -1,5 +1,5 @@
{
- "name": "@dunky.dev/state-machine-solid",
+ "name": "@dunky.dev/solid-state-machine",
"version": "0.2.0",
"license": "MIT",
"repository": {
@@ -8,7 +8,8 @@
"directory": "packages/solid"
},
"files": [
- "dist"
+ "dist",
+ "src"
],
"type": "module",
"sideEffects": false,
@@ -33,10 +34,11 @@
"build": "tsdown"
},
"dependencies": {
- "@dunky.dev/state-machine": "workspace:^",
- "@dunky.dev/state-machine-utils": "workspace:^"
+ "@dunky.dev/state-machine": "workspace:*",
+ "@dunky.dev/state-machine-utils": "workspace:*"
},
"devDependencies": {
+ "@dunky.dev/state-machine-bindings": "workspace:*",
"@solidjs/testing-library": "^0.8.10",
"jsdom": "^29.1.1",
"solid-js": "^1.9.13"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6f42f56..463dd58 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -198,12 +198,15 @@ importers:
packages/solid:
dependencies:
'@dunky.dev/state-machine':
- specifier: workspace:^
+ specifier: workspace:*
version: link:../core
'@dunky.dev/state-machine-utils':
- specifier: workspace:^
+ specifier: workspace:*
version: link:../shared/utils
devDependencies:
+ '@dunky.dev/state-machine-bindings':
+ specifier: workspace:*
+ version: link:../shared/bindings
'@solidjs/testing-library':
specifier: ^0.8.10
version: 0.8.10(solid-js@1.9.13)
@@ -339,15 +342,15 @@ importers:
sandbox/solid:
dependencies:
+ '@dunky.dev/solid-state-machine':
+ specifier: workspace:^
+ version: link:../../packages/solid
'@dunky.dev/state-machine':
specifier: workspace:^
version: link:../../packages/core
'@dunky.dev/state-machine-bindings':
specifier: workspace:^
version: link:../../packages/shared/bindings
- '@dunky.dev/state-machine-solid':
- specifier: workspace:^
- version: link:../../packages/solid
'@dunky.dev/state-machine-utils':
specifier: workspace:^
version: link:../../packages/shared/utils
diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json
index 06834b0..435935f 100644
--- a/sandbox/solid/package.json
+++ b/sandbox/solid/package.json
@@ -9,9 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
+ "@dunky.dev/solid-state-machine": "workspace:^",
"@dunky.dev/state-machine": "workspace:^",
"@dunky.dev/state-machine-bindings": "workspace:^",
- "@dunky.dev/state-machine-solid": "workspace:^",
"@dunky.dev/state-machine-utils": "workspace:^",
"@sandbox/cmdk-core": "workspace:^",
"solid-js": "^1.9.13"
diff --git a/sandbox/solid/src/command-palette.tsx b/sandbox/solid/src/command-palette.tsx
index d2a5bd6..06c2952 100644
--- a/sandbox/solid/src/command-palette.tsx
+++ b/sandbox/solid/src/command-palette.tsx
@@ -1,5 +1,5 @@
import { createEffect, For, type JSX, Show } from 'solid-js'
-import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/state-machine-solid'
+import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/solid-state-machine'
import {
commandPaletteMachineConfig,
type CommandPaletteMachine,
diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts
index 30b1ab2..7eaf45e 100644
--- a/sandbox/solid/vite.config.ts
+++ b/sandbox/solid/vite.config.ts
@@ -10,7 +10,7 @@ export default defineConfig({
resolve: {
alias: {
'@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'),
- '@dunky.dev/state-machine-solid': resolve(__dirname, '../../packages/solid/src'),
+ '@dunky.dev/solid-state-machine': resolve(__dirname, '../../packages/solid/src'),
'@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'),
'@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'),
'@sandbox/cmdk-core': resolve(__dirname, '../shared/src'),
diff --git a/tsconfig/base.json b/tsconfig/base.json
index 4ec153b..49f680f 100644
--- a/tsconfig/base.json
+++ b/tsconfig/base.json
@@ -15,7 +15,7 @@
"paths": {
"@dunky.dev/state-machine": ["../packages/core/src"],
"@dunky.dev/react-state-machine": ["../packages/react/src"],
- "@dunky.dev/state-machine-solid": ["../packages/solid/src"],
+ "@dunky.dev/solid-state-machine": ["../packages/solid/src"],
"@dunky.dev/native-state-machine": ["../packages/native/src"],
"@dunky.dev/opentui-state-machine": ["../packages/opentui/src"],
"@dunky.dev/state-machine-utils": ["../packages/shared/utils/src"],
From c33432d770aef97b846c88abc87f06c615a732f8 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Mon, 17 Aug 2026 23:39:59 +0200
Subject: [PATCH 04/21] feat(solid): adopt the bindings translation contract
and current target APIs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
packages/solid/src/index.ts | 2 +-
packages/solid/src/merge-props.ts | 20 ++++++-----
packages/solid/src/normalize.ts | 39 ++++++++++++++++------
packages/solid/src/use-machine.ts | 23 +++++--------
packages/solid/tests/merge-props.test.ts | 18 ++++++++--
packages/solid/tests/normalize.test.ts | 11 +++++-
packages/solid/tests/use-machine.test.tsx | 10 +++---
packages/solid/tests/use-selector.test.tsx | 2 +-
8 files changed, 82 insertions(+), 43 deletions(-)
diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts
index d113d89..eac700c 100644
--- a/packages/solid/src/index.ts
+++ b/packages/solid/src/index.ts
@@ -1,4 +1,4 @@
-export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine'
+export { useMachine, type ComponentEffect } from './use-machine'
export { useSelector } from './use-selector'
export { normalize, type Bindings } from './normalize'
export { mergeProps } from './merge-props'
diff --git a/packages/solid/src/merge-props.ts b/packages/solid/src/merge-props.ts
index 93be260..419e714 100644
--- a/packages/solid/src/merge-props.ts
+++ b/packages/solid/src/merge-props.ts
@@ -16,18 +16,22 @@ type AnyProps = Record
* library-wins; mixing a string and an object on the same element is a consumer
* error Solid itself wouldn't merge either.)
*/
-export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
- const merged = baseMergeProps(consumer, library)
- if (!consumer) return merged
+export function mergeProps(
+ consumer: Props | undefined,
+ library: AnyProps,
+): Props & AnyProps {
+ const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library)
+ if (!consumer) return merged as Props & AnyProps
+ const own = consumer as AnyProps
- if (typeof consumer.class === 'string' && typeof library.class === 'string') {
- merged.class = `${consumer.class} ${library.class}`.trim()
+ if (typeof own.class === 'string' && typeof library.class === 'string') {
+ merged.class = `${own.class} ${library.class}`.trim()
}
- if (isStyleObject(consumer.style) && isStyleObject(library.style)) {
- merged.style = { ...consumer.style, ...library.style }
+ if (isStyleObject(own.style) && isStyleObject(library.style)) {
+ merged.style = { ...own.style, ...library.style }
}
- return merged
+ return merged as Props & AnyProps
}
function isStyleObject(v: unknown): v is Record {
diff --git a/packages/solid/src/normalize.ts b/packages/solid/src/normalize.ts
index 847d09a..f51d8f3 100644
--- a/packages/solid/src/normalize.ts
+++ b/packages/solid/src/normalize.ts
@@ -1,8 +1,21 @@
/**
* Translate the machine layer's LOGICAL surface to Solid DOM props.
*
- * Logical handler → DOM event prop
- * Logical attr → DOM/ARIA attr
+ * Input keys are the substrate-agnostic vocabulary a connect() emits
+ * (`EventBindings` / `AttrBindings` in `@dunky.dev/state-machine-bindings`),
+ * which is ARIA-shaped by design — see `ACCESSIBILITY.md`. The DOM is the
+ * closest host to that vocabulary, so most attrs are a mechanical `aria-`
+ * prefix and nothing is dropped. The parts that aren't mechanical:
+ * - `onPress` → `onClick`: the DOM's activation event, which fires for
+ * keyboard Enter/Space on a native control too, not just a mouse press.
+ * - `focusable` → `tabindex` 0 / -1, not a boolean — `false` still has to
+ * leave the element focusable in script.
+ * - `disabled` → `aria-disabled`, never the HTML `disabled` attribute: a
+ * disabled control stays in the tab order and keeps announcing itself,
+ * per APG. A consumer that wants the HTML attribute passes it themselves.
+ * - `onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` also have their
+ * argument translated — the DOM event is read into the neutral payload
+ * shape (see PAYLOAD_ADAPTERS), never forwarded raw.
*
* Differences from the React DOM normalizer worth flagging:
*
@@ -10,13 +23,21 @@
* `onClick` is fine (Solid delegates it), and the event handed to a handler is
* a real `MouseEvent`/`PointerEvent`/`KeyboardEvent`/`WheelEvent`, so the
* payload adapters below read the native event shape (same field names).
+ * - `onValueChange` lands on `onInput` (Solid's per-change event; Solid's
+ * `onChange` fires only on commit) and `onDoublePress` on `onDblClick`.
* - Solid uses lowercase `tabindex` (the real attribute), not React's camelCase
* `tabIndex`. That's the only attr name that differs from the DOM normalizer —
* the ARIA attributes (`aria-*`) are written verbatim in Solid JSX, exactly as
* here.
*/
+import type {
+ AttrKey,
+ AttrTargets,
+ HandlerKey,
+ HandlerTargets,
+} from '@dunky.dev/state-machine-bindings'
-const HANDLER_MAP: Record = {
+export const HANDLER_MAP: HandlerTargets = {
onPress: 'onClick',
onPointerEnter: 'onPointerEnter',
onPointerLeave: 'onPointerLeave',
@@ -91,7 +112,7 @@ function scrollPayload(e: AnyEvent): unknown {
}
}
-const ATTR_MAP: Record = {
+export const ATTR_MAP: AttrTargets = {
describedBy: 'aria-describedby',
labelledBy: 'aria-labelledby',
controls: 'aria-controls',
@@ -153,7 +174,7 @@ export function normalize(logical: Bindings): Record {
for (const [key, value] of Object.entries(logical)) {
if (value === undefined) continue
- const handler = HANDLER_MAP[key]
+ const handler = HANDLER_MAP[key as HandlerKey]
if (handler) {
const adapt = PAYLOAD_ADAPTERS[key]
// Wrap when the agnostic payload differs from the raw DOM event; else the
@@ -162,13 +183,9 @@ export function normalize(logical: Bindings): Record {
continue
}
- const attr = ATTR_MAP[key]
+ const attr = ATTR_MAP[key as AttrKey]
if (attr) {
- if (key === 'focusable') {
- out[attr] = value ? 0 : -1
- } else {
- out[attr] = value
- }
+ out[attr] = key === 'focusable' ? (value ? 0 : -1) : value
continue
}
diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts
index b7adf99..63251d0 100644
--- a/packages/solid/src/use-machine.ts
+++ b/packages/solid/src/use-machine.ts
@@ -22,25 +22,20 @@ import { connector, machine, type Connect, type TransitionConfig } from '@dunky.
* component's effects are authored ONCE and run unchanged everywhere; only how
* the bridge consumes the deps differs (a manual dep array on React, reactive
* reads here).
+ *
+ * A component passes `useMachine` a plain `ComponentEffect[]` list — several
+ * independent effects with DIFFERENT deps each get their own `createEffect`, so
+ * only the one whose dep changed re-subscribes. Unlike React there's no
+ * rules-of-hooks constraint here (a `createEffect` is not a hook), but keeping
+ * the list a stable module constant (`export const xEffects = [...]`) is still
+ * the convention — it reads identically across targets and never rebuilds the
+ * effect closures per call.
*/
export type ComponentEffect = [
effect: (machine: Machine, props: Props) => (() => void) | void,
deps: (keyof Props)[],
]
-/**
- * A component's full set of substrate effects — a list, since one component can
- * have several independent effects with DIFFERENT deps (e.g. an Escape listener
- * gated by `closeOnEscape` and a Tab trap gated by `focusTrap`). Each gets its
- * own `createEffect` so only the one whose dep changed re-subscribes.
- *
- * Unlike React there's no rules-of-hooks constraint here (a `createEffect` is
- * not a hook), but keeping it a stable module constant (`export const xEffects =
- * [...]`) is still the convention — it reads identically across targets and
- * never rebuilds the effect closures per call.
- */
-export type ComponentEffects = ComponentEffect[]
-
/**
* The one generic Solid bridge. Every component's generated api.ts calls this
* with the agnostic pieces — a config factory and the connect — plus the
@@ -74,7 +69,7 @@ export function useMachine<
>(
createConfig: (props: Props) => TransitionConfig,
connect: Connect,
- effects: ComponentEffects>, Props>,
+ effects: ComponentEffect>, Props>[],
props: Props,
): { api: Api; machine: ReturnType> } {
// Build machine + connector once. A Solid component body runs a single time,
diff --git a/packages/solid/tests/merge-props.test.ts b/packages/solid/tests/merge-props.test.ts
index cec0e17..0b121e0 100644
--- a/packages/solid/tests/merge-props.test.ts
+++ b/packages/solid/tests/merge-props.test.ts
@@ -5,8 +5,8 @@
* and `style` merged into ONE object (Solid's style is an object, not React's
* array form).
*/
-import { describe, expect, it, vi } from 'vitest'
-import { mergeProps } from '../src'
+import { describe, expect, expectTypeOf, it, vi } from 'vitest'
+import { mergeProps } from '@dunky.dev/solid-state-machine'
describe('solid mergeProps', () => {
it('inherits handler composition from the agnostic base', () => {
@@ -67,3 +67,17 @@ describe('solid mergeProps', () => {
expect(out.class).toBe('x')
})
})
+
+describe('solid mergeProps typing', () => {
+ it('preserves a typed consumer through the style merge', () => {
+ interface ButtonLikeProps {
+ style?: Record
+ class?: string
+ onClick?: () => void
+ }
+ const consumer: ButtonLikeProps = { style: { color: 'red' } }
+ const out = mergeProps(consumer, { style: { color: 'blue' } })
+ expectTypeOf(out).toExtend()
+ expect(out.style).toEqual({ color: 'blue' })
+ })
+})
diff --git a/packages/solid/tests/normalize.test.ts b/packages/solid/tests/normalize.test.ts
index c31de38..757da8d 100644
--- a/packages/solid/tests/normalize.test.ts
+++ b/packages/solid/tests/normalize.test.ts
@@ -9,7 +9,9 @@
* tabindex` (lowercase).
*/
import { describe, expect, it, vi } from 'vitest'
-import { normalize } from '../src'
+import { normalize } from '@dunky.dev/solid-state-machine'
+import { ATTR_MAP, HANDLER_MAP } from '../src/normalize'
+import { describeVocabularyAccounting } from '../../shared/bindings/tests/fixtures/vocabulary-accounting'
describe('solid normalize — handlers', () => {
it('maps onPress to onClick (the DOM activation event)', () => {
@@ -51,6 +53,11 @@ describe('solid normalize — attributes', () => {
})
})
+ it('maps hasPopup to aria-haspopup (string or boolean)', () => {
+ expect(normalize({ hasPopup: 'menu' })).toEqual({ 'aria-haspopup': 'menu' })
+ expect(normalize({ hasPopup: true })).toEqual({ 'aria-haspopup': true })
+ })
+
it('maps the boolean state attrs to their aria-* equivalents', () => {
expect(
normalize({ expanded: true, selected: false, disabled: true, hidden: false, modal: true }),
@@ -264,3 +271,5 @@ describe('solid normalize — expanded attribute surface', () => {
expect(onValueChange).toHaveBeenCalledWith(expect.objectContaining({ value: '50' }))
})
})
+
+describeVocabularyAccounting('solid', normalize, { map: HANDLER_MAP }, { map: ATTR_MAP })
diff --git a/packages/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx
index 8930f17..07b2921 100644
--- a/packages/solid/tests/use-machine.test.tsx
+++ b/packages/solid/tests/use-machine.test.tsx
@@ -17,7 +17,7 @@ import {
type Connect,
type TransitionConfig,
} from '@dunky.dev/state-machine'
-import { type ComponentEffects, useMachine } from '../src'
+import { type ComponentEffect, useMachine } from '@dunky.dev/solid-state-machine'
type ToggleState = 'closed' | 'open'
interface ToggleCtx {
@@ -70,7 +70,7 @@ connect.reactions = [
]
type ToggleMachine = ReturnType>
-const noEffects: ComponentEffects = []
+const noEffects: ComponentEffect[] = []
afterEach(() => vi.clearAllMocks())
@@ -214,7 +214,7 @@ describe('useMachine — component effects', () => {
it('runs each ComponentEffect (setup on mount, cleanup on unmount)', () => {
const setup = vi.fn()
const cleanup = vi.fn()
- const effects: ComponentEffects = [[() => (setup(), cleanup), []]]
+ const effects: ComponentEffect[] = [[() => (setup(), cleanup), []]]
function Comp() {
const props: ToggleProps = {}
useMachine(createConfig(), connect, effects, props)
@@ -229,7 +229,7 @@ describe('useMachine — component effects', () => {
it('re-runs an effect ONLY when one of its named prop deps changes', () => {
const fn = vi.fn(() => () => {})
- const effects: ComponentEffects = [[fn, ['label']]]
+ const effects: ComponentEffect[] = [[fn, ['label']]]
const [label, setLabel] = createSignal('a')
const [other, setOther] = createSignal(() => {})
function Comp() {
@@ -256,7 +256,7 @@ describe('useMachine — component effects', () => {
it('receives (machine, props) and can read live machine state', () => {
let seenOpen: boolean | undefined
- const effects: ComponentEffects = [
+ const effects: ComponentEffect[] = [
[
m => {
seenOpen = m.matches('open')
diff --git a/packages/solid/tests/use-selector.test.tsx b/packages/solid/tests/use-selector.test.tsx
index 4483acc..c582576 100644
--- a/packages/solid/tests/use-selector.test.tsx
+++ b/packages/solid/tests/use-selector.test.tsx
@@ -10,7 +10,7 @@ import { createEffect } from 'solid-js'
import { render, renderHook } from '@solidjs/testing-library'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act as write, machine, type TransitionConfig } from '@dunky.dev/state-machine'
-import { useSelector } from '../src'
+import { useSelector } from '@dunky.dev/solid-state-machine'
type S = 'idle'
interface Ctx {
From 4c28cc2157db45af5e3c82ca115ce4ee1e73fd06 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Mon, 17 Aug 2026 23:40:11 +0200
Subject: [PATCH 05/21] fix(sandbox): harden the solid demo and align the demo
copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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 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
---
sandbox/README.md | 34 +++++++++++++++++----------
sandbox/react/index.html | 2 +-
sandbox/react/src/app.tsx | 4 ++--
sandbox/solid/src/app.tsx | 2 +-
sandbox/solid/src/command-palette.tsx | 9 +++++--
sandbox/solid/vite.config.ts | 4 ++++
6 files changed, 36 insertions(+), 19 deletions(-)
diff --git a/sandbox/README.md b/sandbox/README.md
index 3adf23c..498dde7 100644
--- a/sandbox/README.md
+++ b/sandbox/README.md
@@ -1,7 +1,7 @@
-# cmdk sandbox — one machine, three substrates
+# cmdk sandbox — one machine, four substrates
A ⌘K **command palette** driven by a single substrate-agnostic state machine,
-rendered three ways. The interesting parts — fuzzy filtering, arrow-key
+rendered four ways. The interesting parts — fuzzy filtering, arrow-key
navigation with wraparound, active-row tracking, selection — all live in
`shared/`, the same bytes on every target. Each app only supplies the markup and
runs its substrate's `normalize()` over the bindings the shared `connect()`
@@ -9,25 +9,33 @@ produces.
```
sandbox/
-├── shared/ @sandbox/cmdk-core — the machine + connect() + commands (NO framework)
-├── react/ Vite + React DOM → normalize → onClick / aria-* / role
-├── opentui/ Bun + @opentui/react → normalize → onMouseDown / focusable / cells
-└── native/ Expo + React Native → normalize → onPress / accessibilityState
++-- shared/ @sandbox/cmdk-core — the machine + connect() + commands (NO framework)
++-- react/ Vite + React DOM → normalize → onClick / aria-* / role
++-- solid/ Vite + Solid → normalize → onClick / aria-* / tabindex
++-- opentui/ Bun + @opentui/react → normalize → onMouseDown / focusable / cells
++-- native/ Expo + React Native → normalize → onPress / accessibilityState
```
-The split that makes this work: the lifecycle hook (`useMachine`) comes from
-`@dunky.dev/react-state-machine` — all three targets render through a React
-reconciler — while the **prop translator** (`normalize`) comes from each target's
-own package. The OpenTUI app is the clearest proof: it imports `useMachine` from
-the React binding and `normalize` from `@dunky.dev/opentui-state-machine`, exactly
+The split that makes this work: the **prop translator** (`normalize`) comes from
+each target's own package, while the lifecycle hook (`useMachine`) comes from
+whichever bridge fits the substrate. React, OpenTUI, and React Native all render
+through a React reconciler, so they share `@dunky.dev/react-state-machine`'s
+hook — the OpenTUI app is the clearest proof: it imports `useMachine` from the
+React binding and `normalize` from `@dunky.dev/opentui-state-machine`, exactly
the "bring your own framework hook, pair it with the agnostic translator" model.
+Solid is the proof from the other side: `@dunky.dev/solid-state-machine` brings
+its own fine-grained `useMachine` — no React reconciler anywhere — and the same
+shared machine + `connect()` run unchanged.
## Run
```bash
-# DOM — opens at http://localhost:5173
+# DOM (React) — opens at http://localhost:5173
pnpm -C sandbox/react dev
+# DOM (Solid) — opens at http://localhost:5173
+pnpm -C sandbox/solid dev
+
# Terminal — needs Bun. Press ⌘K / Ctrl+K to open the palette.
pnpm -C sandbox/opentui dev
@@ -35,5 +43,5 @@ pnpm -C sandbox/opentui dev
pnpm -C sandbox/native start # then press i / a, or scan the QR
```
-All three consume the workspace packages straight from their TypeScript `src/`
+All four consume the workspace packages straight from their TypeScript `src/`
(Vite alias / Bun workspace / Metro watch-folders) — no build step.
diff --git a/sandbox/react/index.html b/sandbox/react/index.html
index 216ff7f..8fc30d7 100644
--- a/sandbox/react/index.html
+++ b/sandbox/react/index.html
@@ -3,7 +3,7 @@
- cmdk · DOM
+ cmdk · React
diff --git a/sandbox/react/src/app.tsx b/sandbox/react/src/app.tsx
index 90291a0..7d23059 100644
--- a/sandbox/react/src/app.tsx
+++ b/sandbox/react/src/app.tsx
@@ -7,7 +7,7 @@ export function App() {
return (
-
⌘K Command Pallete
+
⌘K Command Palette
One state machine drives this ⌘K palette.
- The same machine + connect runs the terminal (OpenTUI) and React Native versions
+ The same machine + connect runs the Solid, terminal (OpenTUI) and React Native versions
Last selected: {last}
diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx
index d74e46e..8e167d2 100644
--- a/sandbox/solid/src/app.tsx
+++ b/sandbox/solid/src/app.tsx
@@ -8,7 +8,7 @@ export function App() {
return (
-
⌘K Command Pallete
+
⌘K Command Palette
api.setOpen(false)}>
e.stopPropagation()}>
(inputEl = el)}
+ // Clear on the Show branch's disposal — otherwise the closed
+ // palette keeps a detached alive until the next open.
+ ref={el => {
+ inputEl = el
+ onCleanup(() => (inputEl = undefined))
+ }}
{...normalize(api.parts.input)}
value={api.query}
placeholder='Type a command…'
diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts
index 7eaf45e..f0f892a 100644
--- a/sandbox/solid/vite.config.ts
+++ b/sandbox/solid/vite.config.ts
@@ -8,6 +8,10 @@ import { defineConfig } from 'vite'
export default defineConfig({
plugins: [solid()],
resolve: {
+ // The package alias points Vite inside packages/solid, which carries its own
+ // solid-js devDep — dedupe so the app and the package share ONE Solid runtime
+ // (two copies means silently dead reactivity on any version skew).
+ dedupe: ['solid-js'],
alias: {
'@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'),
'@dunky.dev/solid-state-machine': resolve(__dirname, '../../packages/solid/src'),
From 35ad7598f2ab7c0a2abce4bb4b2d7cdc6dbbdce4 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Mon, 17 Aug 2026 23:40:35 +0200
Subject: [PATCH 06/21] docs: add solid to every target enumeration and fix the
solid docs page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
ACCESSIBILITY.md | 1 +
AGENTS.md | 14 +-
ARCHITECTURE.md | 22 ++--
README.md | 16 +--
packages/solid/README.md | 157 +++++++++++++++++++----
tsconfig.json | 2 +-
website/src/content/docs/api/effects.mdx | 7 +-
website/src/content/docs/libs/solid.mdx | 53 ++++----
8 files changed, 188 insertions(+), 84 deletions(-)
diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md
index bfbb779..f073b9b 100644
--- a/ACCESSIBILITY.md
+++ b/ACCESSIBILITY.md
@@ -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)
```
diff --git a/AGENTS.md b/AGENTS.md
index e55c135..ae48362 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
@@ -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
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 421a558..24cedc0 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -29,7 +29,7 @@ The host
| bridged per target
v
+------------------------------------------------------------------------+
-| (react, native, opentui, …) |
+| (react, solid, native, opentui, …) |
| Runtime-specific bridge |
| • lifecycle (build + start/stop) • normalize bindings -> props |
| • selector subscription |
@@ -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).
-**`/`** is the substrate side — `react`, `native`, `opentui`, and any
+**`/`** 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.
@@ -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//` | 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//` | Hook + normalize per substrate (react, solid, native, opentui, …) |
## The map
@@ -121,7 +121,7 @@ shared/bindings substrate-agnostic event + attr vocabulary
shared/utils cross-target, cross-component helpers
+-- (composeHandlers, positioning, memo, mergeProps)
- one substrate (react, native, opentui, …)
+ 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))
@@ -136,7 +136,7 @@ Three package groups, three jobs:
event + attr vocabulary; `shared/utils` owns agnostic helpers (positioning,
prop merging, memoization).
- **`/`** — _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
@@ -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//*` (`react`, `native`, `opentui`, …). |
+| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`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. |
diff --git a/README.md b/README.md
index 19362cc..a939b75 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/packages/solid/README.md b/packages/solid/README.md
index 962fddf..66f2679 100644
--- a/packages/solid/README.md
+++ b/packages/solid/README.md
@@ -1,11 +1,36 @@
-# `@dunky.dev/state-machine-solid`
+# `@dunky.dev/solid-state-machine`
-The **Solid bindings** for [`@dunky.dev/state-machine`](../core/README.md). The
-core engine is renderer-agnostic; this package is the thin Solid edge that drives
-it: it builds the machine + connector, runs the Solid lifecycle, mirrors the
-connector's snapshot into a fine-grained store, translates the agnostic
-[bindings](../core/README.md#connector--the-view-boundary) vocabulary into DOM
-props, and owns the per-component substrate effects.
+The **Solid bindings** for [`@dunky.dev/state-machine`](../core/README.md).
+
+The behavior lives in the core machine — plain TypeScript, no renderer. This
+package is the thin Solid edge that runs it. It does four things:
+
+1. **`useMachine`** — build the machine once, run its lifecycle, mirror its
+ snapshot into a fine-grained store, run the component's platform effects.
+2. **`useSelector`** — wake a leaf component only when one slice changes.
+3. **`normalize`** — translate the machine's agnostic bindings (`onPress`,
+ `checked`) into real DOM props (`onClick`, `aria-checked`).
+4. **`mergeProps`** — merge the consumer's props with the component's.
+
+```
+ core (agnostic)
+ |
+ | config + connect() behavior + snapshot -> view api
+ |
+ v
+ this package (Solid)
+ |
+ | useMachine build + start the machine, subscribe
+ | |
+ | v
+ | api fine-grained store proxy
+ | |
+ | v
+ | normalize() DOM / ARIA / events
+ |
+ v
+