Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a1b723d
feat(svelte): add Svelte 5 bindings package
ivanbanov Jun 23, 2026
c1a5b11
feat(sandbox): add Svelte 5 cmdk command-palette sandbox
ivanbanov Jun 23, 2026
ab5c0c0
Merge branch 'main' into svelte
ivanbanov Jun 25, 2026
401a30b
chore: fix changelog formatting
ivanbanov Jun 25, 2026
d02572f
ci: retrigger checks
ivanbanov Jun 25, 2026
0061cb2
chore: fix formatting
ivanbanov Jun 25, 2026
d1c032b
Merge branch 'main' into svelte
ivanbanov Jun 30, 2026
a330ca6
Merge branch 'main' into svelte
ivanbanov Jul 10, 2026
f42ce3e
Merge branch 'main' into svelte
ivanbanov Jul 13, 2026
c63951c
Merge branch 'main' into svelte
ivanbanov Oct 4, 2026
2bf9d53
refactor(svelte): adopt the substrate-prefix name and main's manifest…
ivanbanov Oct 4, 2026
d3f7443
test(svelte): run the suite as a package-local vitest project
ivanbanov Oct 4, 2026
eaf11c1
fix(svelte): translate bindings through @dunky.dev/state-machine-dom
ivanbanov Oct 4, 2026
67a6958
fix(svelte): build mergeProps on the shared base and composeHandlers
ivanbanov Oct 4, 2026
ff62e90
fix(svelte): make useMachine and useSelector track props and the mach…
ivanbanov Oct 4, 2026
c64ad20
build(svelte): ship dist through @sveltejs/package, typecheck .svelte…
ivanbanov Oct 4, 2026
dcab914
feat(sandbox): align the svelte cmdk demo with the other web sandboxes
ivanbanov Oct 5, 2026
fc30108
fix(svelte): declare the Svelte versions the bindings work on
ivanbanov Oct 5, 2026
3a2ef2a
docs: document the svelte target and add it to every target listing
ivanbanov Oct 5, 2026
f31a492
chore(changeset): describe the svelte target for consumers
ivanbanov Oct 5, 2026
b8463d0
chore(sandbox): drop the svelte demo's alias-only dependencies
ivanbanov Oct 5, 2026
a436d1f
test(svelte): keep the normalize suite to what Svelte owns
ivanbanov Oct 5, 2026
fda798c
fix(svelte): keep sends untracked, derive the api lazily, stop before…
ivanbanov Oct 5, 2026
188436a
fix(svelte): one-pass mergeProps, attachment-safe props, an explicit …
ivanbanov Oct 5, 2026
65f9ab3
fix(sandbox): focus the svelte palette on open only; ASCII arrows in …
ivanbanov Oct 5, 2026
29558dc
build: run each package's typecheck script from the root
ivanbanov Oct 5, 2026
567a699
docs(svelte): describe the lazy api, untracked sends, and the effect …
ivanbanov Oct 5, 2026
8bb239f
fix(svelte): read the api through a plain getter so the server sees i…
ivanbanov Oct 5, 2026
099ab06
docs(svelte): describe the api as a lazy read, not a derived
ivanbanov Oct 5, 2026
20c2767
test(svelte): declare the deep-state prop the harness passes to connect
ivanbanov Oct 5, 2026
01da4f1
fix(svelte): untrack every reaction, serve a settled api mid-send, Ob…
ivanbanov Oct 6, 2026
4e8ac72
fix(svelte): useSelector reads live on the server, one subscription p…
ivanbanov Oct 6, 2026
dcffbb5
test(svelte): fold the per-name normalize tests into the DOM suite; n…
ivanbanov Oct 6, 2026
12f640c
docs(svelte): type-checked samples, the effect rules, and when Vitest…
ivanbanov Oct 6, 2026
32e77a3
fix(svelte): let a send settle before Svelte hears of it
ivanbanov Oct 7, 2026
339d313
fix(svelte): keep a dropped reader's isEqual and teardown reads off t…
ivanbanov Oct 7, 2026
81142c5
docs(svelte): the settle-then-refresh rule, flushSync, and the unguar…
ivanbanov Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/svelte-integration.md
Original file line number Diff line number Diff line change
@@ -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
<script lang="ts">
import { normalize, useMachine } from '@dunky.dev/svelte-state-machine'

let props: DialogProps = $props()
const view = useMachine(createDialogConfig, connectDialog, dialogEffects, () => props)
</script>

<button {...normalize(view.api.triggerProps)}>Open</button>
```

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).
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dist
.DS_Store
*.log
.vite
.svelte-kit
*.tsbuildinfo
.expo

Expand Down
1 change: 1 addition & 0 deletions .lintstagedrc.json
Original file line number Diff line number Diff line change
@@ -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"]
}
1 change: 1 addition & 0 deletions ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down
18 changes: 9 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

## Scopes

| Scope | Path | What it is |
| --------- | ------------- | ----------------------------------------------------------------------------------------- |
| Packages | `packages/**` | The core machine, substrate targets (react, 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
Expand Down
25 changes: 13 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The host
| bridged per target
v
+------------------------------------------------------------------------+
| <target> (react, solid, native, opentui, …) |
| <target> (react, solid, svelte, native, opentui, …) |
| Runtime-specific bridge |
| • lifecycle (build + start/stop) • normalize bindings -> props |
| • selector subscription |
Expand Down Expand Up @@ -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).

**`<target>/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any
future renderer. Each target is the runtime bridge for one environment: the
**`<target>/`** 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.

Expand Down Expand Up @@ -93,12 +93,12 @@ Zag, whose machines read props directly.)

## Project structure

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

## The map

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

<target> one substrate (react, solid, native, opentui, …)
<target> 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))
Expand All @@ -136,7 +136,8 @@ Three package groups, three jobs:
event + attr vocabulary; `shared/utils` owns agnostic helpers (prop
merging, handler composition).
- **`<target>/`** — _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

Expand Down Expand Up @@ -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/<target>/*` (`react`, `solid`, `native`, `opentui`, …). |
| **target** | A substrate-specific bridge package and its render environment — `packages/<target>/*` (`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. |
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
```
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @dunky.dev/svelte-state-machine
21 changes: 21 additions & 0 deletions packages/svelte/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Ivan Banov

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

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

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading