Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ed44ec2
feat(svelte): the Svelte substrate harness and workspace wiring
ivanbanov Oct 5, 2026
9c79537
feat(svelte): useFocusTrap and useScrollLock over the DOM utils
ivanbanov Oct 5, 2026
d7c8859
feat(svelte): the Dialog primitive
ivanbanov Oct 5, 2026
9ab8e70
docs(stories): the Svelte Dialog story set, mirrored one-for-one
ivanbanov Oct 5, 2026
0d836ac
feat(scaffold): stamp a Svelte binding per primitive
ivanbanov Oct 5, 2026
a2f65d5
docs: add Svelte to the substrates, and record its build exception
ivanbanov Oct 5, 2026
6a7a972
fix(svelte): type the Dialog root as binding nothing, so bind:open is…
ivanbanov Oct 5, 2026
098d28e
fix(svelte): lift the exit window before a reopen moves focus in
ivanbanov Oct 5, 2026
0ef9b23
docs(svelte): one effect per edge, in the substrate's agent notes
ivanbanov Oct 5, 2026
652a21e
test(svelte): inline the published adapter in the Svelte vitest projects
ivanbanov Oct 5, 2026
798e65f
chore(svelte): depend on the adapter's first publish, 0.3.0
ivanbanov Oct 6, 2026
1096d8c
fix(svelte): reach the dialog context through a private key; one meas…
ivanbanov Oct 6, 2026
f991570
fix(svelte): register the layer once the Portal's whole tree is bound
ivanbanov Oct 6, 2026
c635cc1
fix(svelte): keep type="button" when Trigger or Close get type={undef…
ivanbanov Oct 6, 2026
cfda08e
fix(svelte): mount the portalled layers without Svelte intros
ivanbanov Oct 6, 2026
0493afd
test(svelte): hydrate the server render and check its ids survive
ivanbanov Oct 6, 2026
e60c955
docs(svelte): correct the packaging note, the Trigger summary, and a …
ivanbanov Oct 6, 2026
9b779dd
docs(scaffold): state the Svelte context key's reason the right way r…
ivanbanov Oct 6, 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
51 changes: 51 additions & 0 deletions .changeset/svelte-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@dunky.dev/svelte-dialog': minor
---

New substrate: the Svelte binding for `@dunky.dev/dialog`, for Svelte 5. The
same compound anatomy and behavior contract as the React and Solid bindings —
one core machine, a new host — in Svelte's native shape: `<Dialog>` with its
parts hanging off it (`<Dialog.Trigger>`, `<Dialog.Portal>`,
`<Dialog.Content>`, ...), children as snippets, the core options as plain
props, and `onOpenChange` as a callback prop.

The peer range, `svelte` `>=5.20.0 <5.33.5 || ^5.34.5`, is measured — the
suite runs green on its edges: 5.20 is where `$props.id()` arrived (the
SSR-stable base id), and 5.33.5–5.34.4 kept a spread's stale event handlers
(sveltejs/svelte#16180), which every part's attribute spread would hit.

```svelte
<script lang="ts">
import { Dialog } from '@dunky.dev/svelte-dialog'

let open = $state(false)
</script>

<Dialog {open} onOpenChange={next => (open = next)} onEscapeKeyDown={() => (open = false)}>
<Dialog.Trigger onclick={() => (open = true)}>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
<Dialog.Close onclick={() => (open = false)}>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog>
```

The components ship uncompiled — `.svelte` files under the `svelte` export
condition — so your own Svelte compiles them, for the client and for SSR,
instead of a build tied to this repo's Svelte internals. `open` is
deliberately not bindable: a component can't tell `bind:open` from a passed
prop, and backing it would let an unbound controlled dialog move on its own.
Hold the state as above and decide dismissals at their source, per the core
contract. Every element part takes `bind:ref`; `Content`'s `initialFocus` and
`restoreFocus` take an element or a getter (`initialFocus={() => cancelButton}`),
since `bind:this` fills after a component initializes. Everything else follows
the core spec: layer stack with assistive-tech containment, focus trap with
Close as the cycle's last stop, scroll lock (scoped to the Portal container
when given), exit animations through `data-state="closing"`, and
`closeOnBack`.
28 changes: 28 additions & 0 deletions .changeset/svelte-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@dunky.dev/svelte-use-focus-trap': minor
'@dunky.dev/svelte-use-scroll-lock': minor
---

New substrate: the Svelte lifecycle wrappers over the framework-free DOM
utils, in the Solid hooks' shape, for Svelte 5 — the substrate's one peer
range, `svelte` `>=5.20.0 <5.33.5 || ^5.34.5`. Both are rune modules: call
them while a component initializes, like `$effect`.
`useFocusTrap(target, options?)` takes a getter for the container — over
`$state`, a new element re-arms the trap — and `useScrollLock(locked?, target?)`
accepts a `MaybeGetter` for both parameters, so the lock tracks reactive
state. The behavior itself lives in `@dunky.dev/dom-focus-trap` and
`@dunky.dev/dom-scroll-lock`; these packages own only the lifecycle, and ship
uncompiled for your Svelte to compile.

```svelte
<script lang="ts">
import { useFocusTrap } from '@dunky.dev/svelte-use-focus-trap'
import { useScrollLock } from '@dunky.dev/svelte-use-scroll-lock'

let panel: HTMLDivElement | null = $state(null)
useFocusTrap(() => panel)
useScrollLock()
</script>

<div bind:this={panel} role="dialog" tabindex="-1">...</div>
```
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ dist
.vite
storybook-static
*.tsbuildinfo
# @sveltejs/package's scratch copy (it stages the build there before dist)
.svelte-kit

# generated by the on-device Storybook's metro wrapper (see .rnstorybook/index.ts)
storybook.requires.ts
Expand Down
16 changes: 12 additions & 4 deletions .lintstagedrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ const IGNORED = '/scripts/templates/'

const quote = (paths: string[]): string => paths.map(path => JSON.stringify(path)).join(' ')

const checkable = (files: string[]): string[] => files.filter(file => !file.includes(IGNORED))

const config: Configuration = {
'*.{ts,tsx}': files => {
const checkable = files.filter(file => !file.includes(IGNORED))
if (checkable.length === 0) return []
const targets = quote(checkable)
return [`oxlint --fix ${targets}`, `oxfmt ${targets}`]
const targets = checkable(files)
if (targets.length === 0) return []
return [`oxlint --fix ${quote(targets)}`, `oxfmt ${quote(targets)}`]
},
// oxlint reads a component's <script> blocks; oxfmt can't parse `.svelte`
// yet, so these get the lint pass only.
'*.svelte': files => {
const targets = checkable(files)
if (targets.length === 0) return []
return [`oxlint --fix ${quote(targets)}`]
},
}

Expand Down
48 changes: 41 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ one per host environment — the **substrates**. Behavior cannot drift between
hosts because it exists in exactly one place.

A substrate is any environment a primitive is delivered to: a framework
(react), another framework (solid), or a different host entirely
(react), other frameworks (solid, svelte), or a different host entirely
(native). Substrates are cheap by design; the expensive thing — the behavior
— is written once.

Expand Down Expand Up @@ -66,9 +66,9 @@ edges — lives under `dom/components/` instead. A util is primitive-agnostic
and imports nothing from the repo but a smaller util; a component package is
the opposite, and may import the primitive's core package and any DOM util.
Both are equally
framework-free. The split matters as substrates multiply: React and Solid
differ in how they schedule an effect, not in what the effect does, so the
what is written once and each binding contributes only its lifecycle.
framework-free. The split matters as substrates multiply: React, Solid, and
Svelte differ in how they schedule an effect, not in what the effect does, so
the what is written once and each binding contributes only its lifecycle.

Machine logic that several primitives need — the controlled/uncontrolled
machinery (`@dunky.dev/controllable`) — lives the same way under
Expand Down Expand Up @@ -264,16 +264,50 @@ directory into the templates — the script discovers it. See

- The root owns building (tsdown workspace), testing (vitest), lint/format
(oxlint/oxfmt), and releases (changesets) — one config each, no per-package
tooling.
tooling. A substrate whose host needs its own compiler is the exception,
configured per package and recorded below: Solid compiles through a babel
`tsdown.config.ts`, and Svelte builds outside tsdown altogether
([Svelte packages](#svelte-packages)).
- Each substrate owns its dev harness and framework deps — e.g.
`packages/react/.storybook`, run with `pnpm dev:<substrate>`.
- Publishable packages are listed explicitly in `tsdown.config.ts`; a private
package gets a tsconfig path but is never published.
- Publishable packages are listed explicitly in `tsdown.config.ts` (the Svelte
ones aside, see below); a private package gets a tsconfig path but is never
published.
- A published tarball carries `dist`, `src`, and the package's docs
(`README.md`, `SPEC.md`; pnpm copies in the root `LICENSE`). Consumers import
`dist` — `publishConfig` points every entry at it. The source and spec ship
so they can be read.

### Svelte packages

A Svelte package ships uncompiled, built by `@sveltejs/package` instead of
tsdown. Svelte's compiled output targets `svelte/internal/*`, which is no
stable API across Svelte versions, and a server render needs a different
compile than the browser — so only the consumer's own Svelte can compile a
component for the consumer. `svelte-package -i src` emits `dist` file for
file: `.svelte` components and `.svelte.js` rune modules for that compiler,
`.js` from `.ts`, and a `.d.ts` beside each (`.svelte.d.ts` for components).
The `exports` map carries the `types` and `svelte` conditions — the `svelte`
condition is how bundlers and vite-plugin-svelte recognize a package to
compile, SSR included — and publint checks the result, as tsdown does for the
rest. `pnpm build` runs these builds after tsdown (`pnpm build:svelte`), so
`changeset:publish` ships them with everything else.

The alternatives don't hold up: compiling components with a tsdown Svelte
plugin pins every consumer to this repo's Svelte and runtime internals and
leaves SSR out; publishing raw `src` drops the `.d.ts` beside each component
and leaves the `.ts` modules — rune modules included — for the consumer's
bundler to transpile. Components keep their `<script lang="ts">` either way:
Svelte 5 compiles TypeScript in a component natively, so the packager leaves
it in place.

Two consequences in the source. Nothing bundles or rewrites the emitted files,
so relative imports name the file they will load (`./context.js`,
`./use-x.svelte.js`, `./part.svelte`). And the build's declaration emit
(svelte2tsx) and the substrate's typecheck (svelte-check) drive TypeScript's
classic compiler API, which TypeScript 7 no longer ships — the Svelte packages
pin a local `typescript@^6` while the rest of the repo runs 7.

## Versioning

Every package — core primitive, DOM util, substrate binding — versions
Expand Down
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ pnpm install
| Command | What it does |
| ------------------------------ | ---------------------------------------------------------- |
| `pnpm scaffold <name>` | Stamps a new primitive across every substrate |
| `pnpm test` | vitest suite (core + dom + react), watch mode |
| `pnpm test` | vitest suite (core + dom + web substrates), watch mode |
| `pnpm test:native` | The native substrate's jest suite (jest-expo + RNTL) |
| `pnpm test:ci` | Everything once (vitest + native jest) — what CI runs |
| `pnpm typecheck` | `tsc --noEmit` across the whole workspace |
| `pnpm typecheck` | `tsc --noEmit` + svelte-check across the workspace |
| `pnpm lint` | `oxlint` |
| `pnpm format` / `format:check` | `oxfmt` |
| `pnpm knip` | Unused exports/dependencies |
Expand All @@ -36,14 +36,16 @@ pnpm test packages/core/dialog/tests/machine.test.ts

## Storybook

Each UI substrate (React, Solid, ...) is a self-contained package under
`packages/<substrate>` with its own Storybook — the fastest way to see a
Each UI substrate (React, Solid, Svelte, ...) is a self-contained package
under `packages/<substrate>` with its own Storybook — the fastest way to see a
change actually render. Every substrate gets an explicit `dev:<substrate>`
script:

```bash
pnpm dev # alias for dev:react (the default substrate)
pnpm dev:react # @dunky-dev/react Storybook at http://localhost:6006
pnpm dev:solid # @dunky-dev/solid Storybook at http://localhost:6008
pnpm dev:svelte # @dunky-dev/svelte Storybook at http://localhost:6009
pnpm build-storybook # static build of the react substrate's Storybook
```

Expand Down
38 changes: 19 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,23 @@ up there with the same behavior and the same a11y.
connect() -> logical bindings
(onPress, role, data-state, ...)
|
+----------------+----------------+
| |
v |
+------------------------+ |
| dom/components/<name> | the DOM half |
| document listeners, | shared by every |
| focus/stack sequence | DOM host |
+-----+------------+-----+ |
| ^ |
| | dom/utils/* |
| | focus-trap, overlay, |
| | scroll-lock, ... |
v v
+-----------+ +-----------+ +-----------+
| react | | solid | | native |
+-----------+ +-----------+ +-----------+
render + host lifecycle only
+----------------+-----------------------+
| |
v |
+------------------------+ |
| dom/components/<name> | the DOM half |
| document listeners, | shared by every |
| focus/stack sequence | DOM host |
+-----+------------+-----+ |
| ^ |
| | dom/utils/* |
| | focus-trap, overlay, |
| | scroll-lock, ... |
v v
+-----------+ +-----------+ +-----------+ +-----------+
| react | | solid | | svelte | | native |
+-----------+ +-----------+ +-----------+ +-----------+
render + host lifecycle only
```

The engine that runs the core machines lives in its own repo:
Expand All @@ -63,8 +63,8 @@ renders, not how machines execute.
ordered focus sequences), written once and shared by every DOM host.
Published as `@dunky.dev/dom-<name>`.
- **`<substrate>/`** — the render. A thin binding per host (`react/`,
`solid/`, `native/`) that wires the machine to real elements. Published as
`@dunky.dev/<substrate>-<name>` (e.g. `@dunky.dev/react-dialog`).
`solid/`, `svelte/`, `native/`) that wires the machine to real elements.
Published as `@dunky.dev/<substrate>-<name>` (e.g. `@dunky.dev/react-dialog`).

The dependency direction is one-way: `substrate -> dom -> core`. A binding
adds no behavior of its own — if a decision is needed, it moves into the core
Expand Down
3 changes: 3 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const config: KnipConfig = {
// The babel presets are referenced as strings in tsdown.config.ts.
ignoreDependencies: ['babel-preset-solid', '@babel/preset-typescript'],
},
'packages/svelte/*': {
entry: ['stories/*.stories.svelte'],
},
},
}

Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
"test:react": "vitest",
"test:native": "pnpm --filter @dunky-dev/native test",
"test:ci": "vitest run && pnpm test:native",
"build": "tsdown",
"typecheck": "tsc --noEmit && tsc --noEmit -p packages/solid",
"build": "tsdown && pnpm build:svelte",
"build:svelte": "pnpm --filter '@dunky.dev/svelte-*' build",
"typecheck": "tsc --noEmit && tsc --noEmit -p packages/solid && pnpm --filter @dunky-dev/svelte typecheck",
"lint": "oxlint --ignore-pattern '.worktrees' .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
Expand All @@ -18,6 +19,7 @@
"dev": "pnpm dev:react",
"dev:react": "pnpm --filter @dunky-dev/react dev",
"dev:solid": "pnpm --filter @dunky-dev/solid dev",
"dev:svelte": "pnpm --filter @dunky-dev/svelte dev",
"dev:expo": "pnpm --filter @dunky-dev/native dev",
"dev:ios": "pnpm --filter @dunky-dev/native ondevice:ios",
"dev:android": "pnpm --filter @dunky-dev/native ondevice:android",
Expand Down
24 changes: 24 additions & 0 deletions packages/svelte/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { svelte } from '@sveltejs/vite-plugin-svelte'
import type { StorybookConfig } from '@storybook/svelte-vite'

const config: StorybookConfig = {
stories: ['../**/*.stories.@(ts|svelte)'],
addons: ['@storybook/addon-svelte-csf'],
framework: '@storybook/svelte-vite',
core: {
disableTelemetry: true,
disableWhatsNewNotifications: true,
},
features: {
sidebarOnboardingChecklist: false,
},
// The framework compiles nothing itself: it expects vite-plugin-svelte from
// the project's Vite config, ahead of its docgen plugin, which reads the
// compiled output. This harness has no Vite config, so it goes first here.
viteFinal: viteConfig => {
viteConfig.plugins = [svelte({ configFile: false }), ...(viteConfig.plugins ?? [])]
return viteConfig
},
}

export default config
14 changes: 14 additions & 0 deletions packages/svelte/.storybook/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { addons } from 'storybook/manager-api'
import { create } from 'storybook/theming'

addons.setConfig({
showToolbar: true,
layoutCustomisations: {
showPanel: () => false,
},
theme: create({
base: 'light',
brandTitle: 'dunky',
brandUrl: './',
}),
})
38 changes: 38 additions & 0 deletions packages/svelte/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Agents / svelte substrate

Svelte 5 bindings. Everything in the root `AGENTS.md` applies; only the host
differs:

- **Packages ship uncompiled.** `@sveltejs/package` builds each one, not
tsdown — the consumer's Svelte compiles the `.svelte` files and `.svelte.js`
rune modules (see `ARCHITECTURE.md`). Nothing rewrites the emitted imports,
so a relative import names the file it loads: `./context.js` for a `.ts`
module, `./use-x.svelte.js` for a rune module, `./part.svelte` for a
component.
- **One component per file.** Each part is a kebab-case `.svelte` file
(`dialog-trigger.svelte`); `src/index.ts` assembles the compound. A module
that calls runes is a `.svelte.ts` file.
- **Effects run in tree order, children first.** `bind:this` fills in the
same pass, after mount — a part sees a sibling's element only if the sibling
renders before it, so work that needs the whole tree waits for an
ancestor's effect (the dialog's Portal layer marks itself `bound`). The
connected `api` is a fresh snapshot per machine change, so an effect keyed
on one value derives it first (`const open = $derived(api.open)`) and reads
everything else `untrack`ed. Each effect tears down right before its own
re-run, so two effects keyed on the same edge interleave: keep a sequence
and its inverse in one effect.
- **svelte-check typechecks it**, not `tsc`, which can't read `.svelte`
(`pnpm typecheck` runs both). svelte-check and the package build drive
TypeScript's classic API, gone in TypeScript 7, so these packages hold a
local `typescript@^6`.
- **oxfmt can't parse `.svelte`.** oxlint still lints the `<script>` blocks;
keep the markup in the repo's style by hand.
- **Two vitest projects.** `vitest.config.ts` renders through the DOM
(`svelteTesting()`, jsdom per file); `vitest.ssr.config.ts` runs the
`*.ssr.test.ts` files through `svelte/server`. A composition a test renders
is a `.svelte` fixture in `tests/fixtures/`; call `flushSync()` after an
interaction before reading the DOM.
- **Stories are Svelte CSF** (`*.stories.svelte`): `asChild` renders the
composition as written and `exportName` keeps the story ids identical to the
other substrates'. A story that needs local state is a component in
`stories/`.
Loading
Loading