diff --git a/.changeset/svelte-integration.md b/.changeset/svelte-integration.md
new file mode 100644
index 0000000..f7e0338
--- /dev/null
+++ b/.changeset/svelte-integration.md
@@ -0,0 +1,56 @@
+---
+'@dunky.dev/svelte-state-machine': minor
+---
+
+Add `@dunky.dev/svelte-state-machine` — the Svelte 5 bindings target.
+
+A first-class Svelte bridge written in runes (not a React re-export):
+`useMachine` builds the machine and connector once and exposes the connector's
+snapshot as `view.api`, read lazily — `connect()` runs once a transition
+settles, only when read. It starts the machine after mount and stops it on
+destroy before child components tear down (React's order), and runs each
+`ComponentEffect` as its own `$effect`, re-run only when the value of one of its
+named prop deps changes (compared with `Object.is`, like React's dep array).
+Reactions and their callbacks never become dependencies of the `$effect` that
+changed the machine, and a read of `view.api` from inside a `view.machine.send`
+gets the snapshot the previous send settled to rather than a half-applied
+transition; transitions that start elsewhere (`after` timers, an action's own
+async send) aren't guarded, so selectors and callbacks should read the machine.
+`useSelector` returns `{ current }`, takes the machine as a value or a getter,
+renders live on the server, and keeps a selector that throws mid-send out of the
+sender's `send()`. `normalize` maps the agnostic bindings to the props a Svelte
+element spread expects — the DOM's lowercase event attributes (`onclick`,
+`oninput`, `ondblclick`), `tabindex`, `aria-*` — and `mergeProps` chains
+handlers consumer-first with the `defaultPrevented` veto, joins `class` and
+`style` strings, merges other `class` shapes as `[consumer, library]`, and keeps
+symbol-keyed attachments. The same `connect` and machine config run unchanged
+across React, Solid, Svelte, React Native, and OpenTUI.
+
+```svelte
+
+
+
+```
+
+Props go in as a getter (`() => props`): a component's script runs once and
+its props are reactive bindings, so the bridge reads them inside its effects.
+Like every target, it compares props shallowly — pass a new reference to change
+a value `connect` reads. Read `view.api` where you use it instead of
+destructuring it, and read `$derived` slices of it in effects.
+
+The package ships `.svelte.js` runes modules and `.d.ts` files, built with
+`@sveltejs/package`, for the consumer's Svelte build to compile: use it through
+Vite with `@sveltejs/vite-plugin-svelte` or SvelteKit. Vitest needs only that
+same plugin, unless a test harness reaches the adapter solely through other
+packages' source files: then inline it with
+`test.server.deps.inline: [/@dunky\.dev\/svelte-/]`.
+
+The peer range, `svelte` `>=5.16.0 <5.33.5 || ^5.34.5`, is measured: 5.16 is
+where `class` arrays and objects resolve through clsx, which the `mergeProps`
+merge relies on, and 5.33.5–5.34.4 kept stale spread event handlers
+(sveltejs/svelte#16180).
diff --git a/.gitignore b/.gitignore
index f107741..df63aad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ dist
.DS_Store
*.log
.vite
+.svelte-kit
*.tsbuildinfo
.expo
diff --git a/.lintstagedrc.json b/.lintstagedrc.json
index 740db21..ba56762 100644
--- a/.lintstagedrc.json
+++ b/.lintstagedrc.json
@@ -1,5 +1,6 @@
{
"*.{ts,tsx}": ["oxlint --fix --no-error-on-unmatched-pattern", "oxfmt"],
"*.astro": ["oxlint --fix --no-error-on-unmatched-pattern"],
+ "*.svelte": ["oxlint --fix --no-error-on-unmatched-pattern"],
"*.{md,mdx}": ["oxfmt"]
}
diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md
index f073b9b..45b50b4 100644
--- a/ACCESSIBILITY.md
+++ b/ACCESSIBILITY.md
@@ -82,6 +82,7 @@ record:
|
+-- react -> aria-hidden
+-- solid -> aria-hidden
+ +-- svelte -> 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 030105b..da4422c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,9 +5,9 @@ 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`, `solid`, `native`, `opentui`), with
-a benchmark suite, per-substrate sandboxes, and the docs website
-alongside.
+through thin per-substrate targets (`react`, `solid`, `svelte`,
+`native`, `opentui`), with a benchmark suite, per-substrate sandboxes,
+and the docs website alongside.
## Preflight
@@ -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, 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 |
+| Scope | Path | What it is |
+| --------- | ------------- | ------------------------------------------------------------------------------------------------- |
+| Packages | `packages/**` | The core machine, substrate targets (react, solid, svelte, 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 54cec89..b8a8bfb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -29,7 +29,7 @@ The host
| bridged per target
v
+------------------------------------------------------------------------+
-| (react, solid, native, opentui, …) |
+| (react, solid, svelte, native, opentui, …) |
| Runtime-specific bridge |
| • lifecycle (build + start/stop) • normalize bindings -> props |
| • selector subscription |
@@ -58,8 +58,8 @@ 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).
-**`/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any
-future renderer. Each target is the runtime bridge for one environment: the
+**`/`** is the substrate side — `react`, `solid`, `svelte`, `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 |
-| `packages//` | Hook + normalize per substrate (react, solid, 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 |
+| `packages//` | Hook + normalize per substrate (react, solid, svelte, native, opentui, …) |
## The map
@@ -121,7 +121,7 @@ shared/bindings substrate-agnostic event + attr vocabulary
shared/utils cross-target, cross-component helpers
+-- (mergeProps, composeHandlers)
- one substrate (react, solid, native, opentui, …)
+ one substrate (react, solid, svelte, 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,8 @@ Three package groups, three jobs:
event + attr vocabulary; `shared/utils` owns agnostic helpers (prop
merging, handler composition).
- **`/`** — _the substrate side_. One folder per renderer
- (`react`, `solid`, `native`, `opentui`). Owns its runtime bridge and its props translator.
+ (`react`, `solid`, `svelte`, `native`, `opentui`). Owns its runtime bridge and its
+ props translator.
## The machine parts
@@ -177,7 +178,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`, `solid`, `native`, `opentui`, …). |
+| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `solid`, `svelte`, `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/CONTRIBUTING.md b/CONTRIBUTING.md
index eeb6a21..2d0b87f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,7 +19,7 @@ pnpm install
| ------------------------------ | ---------------------------------------------------------- |
| `pnpm test` | Full test suite, watch mode |
| `pnpm test:ci` | Full test suite, once |
-| `pnpm typecheck` | `tsc -b` across the whole workspace |
+| `pnpm typecheck` | `tsc -b` across the workspace, then package `typecheck`s |
| `pnpm lint` | `oxlint` |
| `pnpm format` / `format:check` | `oxfmt` |
| `pnpm build` | Build every publishable package |
@@ -36,6 +36,7 @@ the short version:
```bash
pnpm -C sandbox/react dev
+pnpm -C sandbox/svelte dev
pnpm -C sandbox/opentui dev # terminal — needs Bun
pnpm -C sandbox/native dev # Expo — needs a simulator or device
```
diff --git a/package.json b/package.json
index d835914..28ea995 100644
--- a/package.json
+++ b/package.json
@@ -9,8 +9,8 @@
"benchmark:demo": "pnpm -C benchmark/demo dev",
"website:dev": "pnpm -C website dev",
"website:prod": "pnpm -C website build",
- "build": "tsdown",
- "typecheck": "tsc -b tsconfig.build.json",
+ "build": "tsdown && pnpm --filter @dunky.dev/svelte-state-machine build",
+ "typecheck": "tsc -b tsconfig.build.json && pnpm -r --filter './packages/**' --if-present typecheck",
"lint": "oxlint .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
new file mode 100644
index 0000000..9ce9d42
--- /dev/null
+++ b/packages/svelte/CHANGELOG.md
@@ -0,0 +1 @@
+# @dunky.dev/svelte-state-machine
diff --git a/packages/svelte/LICENSE b/packages/svelte/LICENSE
new file mode 100644
index 0000000..08a9692
--- /dev/null
+++ b/packages/svelte/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/svelte/README.md b/packages/svelte/README.md
new file mode 100644
index 0000000..c070019
--- /dev/null
+++ b/packages/svelte/README.md
@@ -0,0 +1,405 @@
+# `@dunky.dev/svelte-state-machine`
+
+The **Svelte 5 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 Svelte edge that runs it. It does four things:
+
+1. **`useMachine`** — build the machine once, run its lifecycle, expose its
+ snapshot reactively, 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 (Svelte)
+ |
+ | useMachine build + start the machine, subscribe
+ | |
+ | v
+ | view.api the connector's snapshot, read lazily
+ | |
+ | v
+ | normalize() DOM / ARIA / events
+ |
+ v
+