diff --git a/.changeset/svelte-integration.md b/.changeset/svelte-integration.md
new file mode 100644
index 0000000..f705eec
--- /dev/null
+++ b/.changeset/svelte-integration.md
@@ -0,0 +1,5 @@
+---
+'@dunky.dev/state-machine-svelte': minor
+---
+
+Add Svelte 5 bindings: `@dunky.dev/state-machine-svelte`. A thin, runes-based edge layer mirroring the React package — `useMachine` (build-once bridge + lifecycle + prop-scoped substrate effects, returning a reactive `{ api, machine }`), `useSelector` (fine-grained leaf subscription returning `{ current }`), and `normalize`/`mergeProps` for Svelte's DOM idiom (lowercase `on*` event props, `class`/`style` string merge). The package ships its `src` uncompiled so the consumer's Svelte compiler processes its `.svelte.ts` runes modules.
diff --git a/package.json b/package.json
index 1c4a591..59ca78e 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,8 @@
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
+ "@testing-library/svelte": "^5.4.2",
"@types/node": "^22.10.2",
"husky": "^9.1.7",
"knip": "^6.15.0",
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..aba99a8
--- /dev/null
+++ b/packages/svelte/README.md
@@ -0,0 +1,217 @@
+# `@dunky.dev/state-machine-svelte`
+
+The **Svelte 5 bindings** for [`@dunky.dev/state-machine`](../core/README.md).
+The core engine is renderer-agnostic; this package is the thin Svelte edge that
+drives it: it builds the machine + connector, runs the Svelte lifecycle, bridges
+the connector's snapshot into Svelte reactivity (runes), translates the agnostic
+[bindings](../core/README.md#connector--the-view-boundary) vocabulary into DOM
+props, and owns the per-component substrate effects.
+
+Everything here is deliberately small — the behavior lives in the core machine
+and the component's `connect`; this layer only adapts them to Svelte. There are
+four exports: one bridge (`useMachine`, which also runs the component's substrate
+effects), one leaf-subscription helper (`useSelector`), and two prop helpers
+(`normalize`, `mergeProps`) — plus the `ComponentEffect` types.
+
+> **Svelte 5 only.** `useMachine` and `useSelector` use runes (`$state`,
+> `$effect`), so they ship as `.svelte.ts` modules and are compiled by your
+> Svelte build (Vite plugin / SvelteKit), exactly like a component. The package
+> ships its `src` uncompiled for that reason.
+
+---
+
+## `useMachine` — the one bridge
+
+Every component's generated `useXxxApi` calls this with the agnostic pieces:
+
+```svelte
+
+
+
+```
+
+It:
+
+- **builds once** — `machine(createConfig(props))` + `connector(service, connect, props)`.
+ The first props read seeds context and the initial state; recreating would lose
+ state, so later prop changes flow through `setProps`, not a rebuild.
+- **keeps props fresh** via an `$effect` calling `connection.setProps(getProps())`.
+ `getProps()` reads the component's reactive props, so it re-runs when they
+ change; `setProps` value-dedups, so an unchanged read doesn't churn.
+- **runs the lifecycle**: `service.start()` on setup, `service.stop()` on destroy,
+ wired in one `$effect` whose cleanup Svelte calls automatically. 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 with no teardown
+ threading here.
+- **runs the component's substrate effects** — one `$effect` per `ComponentEffect`
+ entry, each reading only its named prop deps (see below).
+- **exposes the snapshot** through a `$state`-backed `view.api` getter, seeded with
+ the connector's initial snapshot and reassigned on each connector notify. The
+ connector memoizes, so the identity changes only on a real change — reading
+ `view.api` in markup updates only then.
+
+Returns `{ api, machine }` (both getters): `api` is the `connect()` output to
+spread onto elements; `machine` is the running service (also handed to
+`useSelector`).
+
+### Why a props getter
+
+React hands `useMachine` a fresh `props` value each render. Svelte props are
+reactive bindings, so the bridge instead takes `() => props` and reads it inside
+its effects — that's how `setProps` and the substrate effects see current values
+without a per-render call. Pass `() => props` (or `() => ({ ...resolved })` after
+applying defaults).
+
+---
+
+## `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
+Svelte-side _effect_.
+
+Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`), the
+**same shape as the React binding** — only how `useMachine` runs it differs:
+
+```ts
+import type { ComponentEffect } from '@dunky.dev/state-machine-svelte'
+
+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 `$effect` per entry**. Each effect reads only
+its named props, so runes wake it _only when one of those values actually
+changes_ (the precise-dependency behavior React got from a manual dep array, here
+from automatic tracking) — never for an unrelated machine change. Returning the
+setup's teardown lets Svelte clean it up on re-run / destroy.
+
+> Unlike React there's no rules-of-hooks constraint, so the list need not be a
+> module constant — but keeping it one (`export const xEffects = [...]`) stays the
+> tidy convention.
+
+> The agnostic _decision_ (gate + veto) belongs in the core component's resolver;
+> only the _transport_ (the DOM listener) is here. Same split as everywhere:
+> agnostic policy in core, platform wiring at the edge.
+
+---
+
+## `useSelector` — fine-grained leaf subscription
+
+For a leaf that should update only when **one slice** of the machine changes (the
+`O(readers)` path that matters at scale — thousands of items, each waking only for
+its own value):
+
+```ts
+const open = useSelector(machine, () => machine.matches('open'))
+// in markup: {#if open.current} … {/if}
+```
+
+It returns `{ current }` — a single reactive getter (a bare value can't carry its
+reactivity across the `return`). The selector reads the machine directly and the
+value updates only when the selected value changes — `Object.is` by default. **A
+selector that returns a fresh object/array each call should pass a custom
+`isEqual`** so an equal-but-new value isn't seen as 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,
+)
+```
+
+Internally it wraps the selector in one `Selection` and subscribes in an
+`$effect`, writing a `$state` cell that `current` reads. The Selection's
+value-dedup gates the update; there's no getSnapshot-identity hazard to guard
+against (unlike React) because `$state` only notifies on reassignment.
+
+**`useMachine` vs. `useSelector`.** `useMachine` drives the whole component off
+the connector's snapshot (memoized, so it updates only on a real change).
+`useSelector` is for _within_ that tree — a child that wants to track just one
+field. Reach for it when a subtree is large enough that whole-snapshot updates are
+wasteful.
+
+---
+
+## `normalize` — agnostic bindings → DOM props
+
+`connect` returns substrate-agnostic [bindings](../core/README.md#connector--the-view-boundary)
+(`onPress`, `describedBy`, `role`). `normalize` translates them to real DOM/ARIA
+props in Svelte's idiom — lowercase `on*` events, `tabindex`, `aria-*`:
+
+```ts
+const domProps = normalize(view.api.triggerProps) // { onclick, 'aria-describedby', role, … }
+```
+
+The mapping mirrors the React DOM normalizer, with two Svelte differences: event
+props are the **lowercase DOM names** (`onclick`, `onkeydown`) rather than
+camelCase synthetic-event props, and `focusable` → `tabindex` (lowercase). The
+`aria-*` names are identical — ARIA is part of the DOM, not the framework. A few
+handlers whose agnostic payload differs from the raw event
+(`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd`) are wrapped so the consumer
+receives the agnostic payload. `undefined` values are dropped, and any key not in
+the map passes through unchanged.
+
+---
+
+## `mergeProps` — combine consumer props with the component's props
+
+When a consumer spreads their own props onto the same element the component
+controls, the two prop sets have to merge sensibly. `mergeProps(consumer, library)`
+does it the Radix/Ark way:
+
+```ts
+const finalProps = mergeProps(consumerProps, normalize(view.api.triggerProps))
+```
+
+- **Event handlers are chained, consumer-first** — both run, the consumer's
+ before the library's — **but if the consumer's handler marks the event
+ `defaultPrevented`, the library handler is skipped.** Detection is on Svelte's
+ lowercase `on*` props.
+- **`style` is concatenated** as a string (Svelte styles are strings, not React's
+ array form), joined with `; ` and trimmed. String + string only; else library
+ wins.
+- **`class` is concatenated** with a single space and trimmed (the Svelte
+ attribute name, React's `className`). String + string only; else library wins.
+- **Everything else: library wins** — the component owns its semantics (`id`,
+ `role`, `aria-*`).
+
+If the consumer passes no props, the library props are returned as-is.
+
+---
+
+## API
+
+| Export | What it is |
+| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
+| `useMachine(config, connect, effects, getProps)` | the bridge — build once + lifecycle + run the component effects + reactive snapshot; returns `{ api, machine }` (getters) |
+| `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice (`O(readers)`); returns `{ current }` |
+| `normalize(bindings)` | agnostic bindings → DOM/ARIA props (lowercase `on*`, `tabindex`, `aria-*`) |
+| `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `style`/`class` concatenated; else library wins) |
+| `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/svelte/package.json b/packages/svelte/package.json
new file mode 100644
index 0000000..b6347c9
--- /dev/null
+++ b/packages/svelte/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@dunky.dev/state-machine-svelte",
+ "version": "0.2.0",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/state-machine.git",
+ "directory": "packages/svelte"
+ },
+ "files": [
+ "src"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "svelte": "./src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "svelte": "./src/index.ts",
+ "default": "./src/index.ts"
+ }
+ },
+ "dependencies": {
+ "@dunky.dev/state-machine": "workspace:^"
+ },
+ "devDependencies": {
+ "svelte": "^5.43.2"
+ },
+ "peerDependencies": {
+ "svelte": "^5"
+ }
+}
diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts
new file mode 100644
index 0000000..fc9954e
--- /dev/null
+++ b/packages/svelte/src/index.ts
@@ -0,0 +1,4 @@
+export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine.svelte'
+export { useSelector } from './use-selector.svelte'
+export { normalize, type Bindings } from './normalize'
+export { mergeProps } from './merge-props'
diff --git a/packages/svelte/src/merge-props.ts b/packages/svelte/src/merge-props.ts
new file mode 100644
index 0000000..dd6b73a
--- /dev/null
+++ b/packages/svelte/src/merge-props.ts
@@ -0,0 +1,71 @@
+type AnyProps = Record
+type AnyHandler = (...args: unknown[]) => unknown
+
+// A Svelte DOM event prop: lowercase `on` + an event name (`onclick`,
+// `onkeydown`, `onpointerenter`). This is where the Svelte merge diverges from
+// the shared `baseMergeProps`, whose detector keys off React's camelCase form
+// (`on` + an UPPERCASE letter). After `normalize`, the library props are all
+// lowercase, so we chain on the lowercase shape instead.
+const isEventHandlerKey = (key: string): boolean =>
+ key.length > 2 && key.startsWith('on') && key[2] !== key[2]!.toUpperCase()
+
+const isFn = (v: unknown): v is AnyHandler => typeof v === 'function'
+
+function compose(consumer: AnyHandler, library: AnyHandler): AnyHandler {
+ return (...args) => {
+ consumer(...args)
+ // Respect consumer's defaultPrevented — if the first arg looks like an event
+ // whose default was prevented, the library handler is skipped. Matches the
+ // Radix/Ark convention the React/base mergers use.
+ const event = args[0] as { defaultPrevented?: boolean } | undefined
+ if (event && typeof event === 'object' && event.defaultPrevented) return
+ return library(...args)
+ }
+}
+
+/**
+ * Merge a consumer's props with the component's (library) props for the same
+ * element — the Svelte counterpart of the React `mergeProps`.
+ *
+ * - **Event handlers are chained, consumer-first**, with the same
+ * `defaultPrevented` veto: if the consumer's handler prevents the event, the
+ * library's is skipped. Detection is on Svelte's lowercase `on*` props.
+ * - **`class` is concatenated** with a single space and trimmed (`'a b'` + `'c'`
+ * → `'a b c'`), the Svelte attribute name (React's `className`). String + string
+ * only; otherwise library wins.
+ * - **`style` is concatenated** as a string (Svelte styles are strings, not the
+ * React array form), joined with `; ` and trimmed. String + string only;
+ * otherwise library wins.
+ * - **Everything else: library wins** — the component owns its semantics
+ * (`id`, `role`, `aria-*`).
+ *
+ * If the consumer passes no props, the library props are returned as-is.
+ */
+export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
+ if (!consumer) return library
+ const out: AnyProps = { ...consumer }
+
+ for (const [key, libValue] of Object.entries(library)) {
+ const consumerValue = consumer[key]
+
+ if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) {
+ out[key] = compose(consumerValue, libValue)
+ continue
+ }
+
+ if (key === 'class' && typeof consumerValue === 'string' && typeof libValue === 'string') {
+ out.class = `${consumerValue} ${libValue}`.trim()
+ continue
+ }
+
+ if (key === 'style' && typeof consumerValue === 'string' && typeof libValue === 'string') {
+ out.style = `${consumerValue.replace(/;\s*$/, '')}; ${libValue}`.trim()
+ continue
+ }
+
+ // Default: library wins.
+ out[key] = libValue
+ }
+
+ return out
+}
diff --git a/packages/svelte/src/normalize.ts b/packages/svelte/src/normalize.ts
new file mode 100644
index 0000000..43723fb
--- /dev/null
+++ b/packages/svelte/src/normalize.ts
@@ -0,0 +1,180 @@
+/**
+ * Translate the machine layer's LOGICAL surface to Svelte DOM props.
+ *
+ * Logical handler → DOM event prop
+ * Logical attr → DOM/ARIA attr
+ *
+ * Differences from the React DOM normalizer worth flagging:
+ *
+ * - Svelte 5 event props are the lowercase DOM attribute names (`onclick`,
+ * `onkeydown`, `onpointerenter`), not React's camelCase synthetic-event props
+ * (`onClick`, `onKeyDown`). Spread onto an element, `{...normalize(api.x)}`
+ * attaches real DOM listeners.
+ * - `focusable` → `tabindex` (lowercase), where React used `tabIndex`.
+ * - The `aria-*` attribute names are identical to React's — ARIA is part of the
+ * DOM, not the framework — so the attr map matches the React one verbatim.
+ * - The payload adapters are identical too: Svelte hands the handler the raw DOM
+ * event, the same shape React's normalizer adapts from.
+ */
+
+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
+// raw DOM event. For those, normalize wraps the handler so the component
+// receives the agnostic payload — built here from the DOM event — rather than
+// the DOM event itself. (onPress/pointer/keyboard handlers already receive a
+// shape that overlaps PointerPayload/KeyboardPayload, so they pass through
+// unwrapped, exactly as onPress always has.)
+
+// 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/svelte/src/use-machine.svelte.ts b/packages/svelte/src/use-machine.svelte.ts
new file mode 100644
index 0000000..c60f20a
--- /dev/null
+++ b/packages/svelte/src/use-machine.svelte.ts
@@ -0,0 +1,149 @@
+///
+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 Svelte. The deps are prop NAMES (typed `(keyof Props)[]`,
+ * so typos are compile errors); the bridge turns them into a tracked `$effect`
+ * so the effect re-subscribes only when one of those props actually changes —
+ * not on every change, never stale. `machine` is always an implicit dep.
+ *
+ * Identical in shape to the React `ComponentEffect` — what changes is only how
+ * `useMachine` runs it (a Svelte `$effect`, not a React `useEffect`).
+ */
+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 `$effect` so only the one whose dep changed re-subscribes.
+ *
+ * Unlike React, Svelte has no rules-of-hooks: `useMachine` sets up the effects
+ * once at call time, so the list need not be a module constant. Keeping it one
+ * (`export const xEffects = [...]`) is still the tidy convention.
+ */
+export type ComponentEffects = ComponentEffect[]
+
+type Service<
+ State extends string,
+ Context extends object,
+ Event extends { type: string },
+ Computed,
+> = ReturnType>
+
+/**
+ * The one generic Svelte bridge. Every component's generated api calls this with
+ * the agnostic pieces — a config factory and the connect — plus the component's
+ * substrate effects and a GETTER for the resolved props:
+ *
+ * const view = useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, () => props)
+ * // then in markup: