Skip to content

feat(svelte): the Svelte substrate, opening with Dialog - #61

Draft
ivanbanov wants to merge 18 commits into
mainfrom
svelte-dialog
Draft

ivanbanov wants to merge 18 commits into
mainfrom
svelte-dialog

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

What

The Svelte 5 substrate, opening with Dialog (peer svelte >=5.20.0 <5.33.5 || ^5.34.5):

  • @dunky.dev/svelte-dialog — <Dialog> with Dialog.Trigger/Portal/Backdrop/Viewport/Content/Title/Description/Close hung off the root, same anatomy and behavior contract as React and Solid. Every DOM sequence comes from @dunky.dev/dom-dialog, every decision from the core machine; the binding adds Svelte's lifecycle only.
  • @dunky.dev/svelte-use-focus-trap / @dunky.dev/svelte-use-scroll-lock — rune modules over the DOM utils, in the Solid hooks' call shape.
  • @dunky-dev/svelte harness — Storybook on 6009 (pnpm dev:svelte, Svelte CSF), a DOM and an SSR vitest project, svelte-check in pnpm typecheck.
  • Scaffold templates under scripts/templates/packages/svelte; docs (README, ARCHITECTURE, CONTRIBUTING, packages/svelte/AGENTS.md); changesets.

Svelte-specific decisions

  • Uncompiled packages. @sveltejs/package emits .svelte / .svelte.js / .js / .d.ts under types + svelte export conditions, so the consumer's Svelte compiles them for client and SSR. pnpm build chains pnpm build:svelte after tsdown; publint runs per package. Recorded in ARCHITECTURE ("Svelte packages").
  • Measured peer range. 5.20 is where $props.id() arrived. 5.33.5–5.34.4 are excluded for stale spread handlers (fix: keep spread non-delegated event handlers up to date sveltejs/svelte#16180), the adapter's exclusion too; a test catches it on 5.34.0. The suites pass on 5.20.0, 5.33.4, 5.34.5, 5.40.0, 5.56.10 and 5.57.1; 5.19 fails to compile. The context uses a private key with set/get/hasContext, which every Svelte 5 has, not createContext.
  • TypeScript 7. svelte-check, svelte2tsx's d.ts emit and svelte-package need TypeScript's classic API, so the Svelte packages hold a local typescript@^6.
  • Portal = mount() + getAllContexts(). Context survives, there is no wrapper element, nothing renders on the server, it re-mounts when container changes, and intro: false keeps Svelte transitions off both ways (animate through data-state). Content registers the layer once the Portal's whole tree is bound, so render order inside a Portal doesn't matter.
  • No bind:open. A component can't tell a bound prop from a passed one, so an unbound write would let a controlled dialog move on its own. Use open + onOpenChange; the root's type binds nothing, so svelte-check rejects bind:open.
  • Effects. Edges key on $derived(api.open) with untracked bodies. The open edge and the exit window share one effect. Ids come from $props.id() with options.id ?? id. Every element part takes bind:ref. type defaults to button even when passed undefined.

Verified

  • 78 Svelte tests: 57 ported 1:1 from React/Solid, plus Svelte corners, SSR, hydration from real server markup, and hooks. 374 vitest + 13 jest in total.
  • pnpm build (publint clean), typecheck (incl. svelte-check), lint, format:check, changeset status. knip matches main's baseline apart from three new tsconfig paths in its existing native list.
  • All three Storybooks build with identical story ids. React-vs-Svelte attribute parity across 11 scenarios is identical apart from nested-stack focus (Svelte focuses the topmost dialog).
  • Pack-and-consume with the packed adapter: client build, SSR render, hydration and consumer svelte-check pass on Svelte 5.57.1 (Vite 8) and at the 5.20.0 floor (Vite 7).

Blocked

Blocked on dunky-dev/state-machine#34 publishing @dunky.dev/svelte-state-machine (first publish 0.3.0). The lockfile is deliberately not committed. Local development uses an uncommitted pnpm override pointing at the packed adapter, so CI's install fails until the adapter is on npm (same as the Solid PR). Once it publishes: land chore/bump-state-machine (the adapter pins @dunky.dev/state-machine@0.3.4), drop the override, then regenerate and commit the lockfile. Follow-ups outside this PR: the shared dom-dialog Escape double-close fix (its own PR), and the React/Solid type={undefined} default and focus-trap README example.

🤖 Generated with Claude Code

ivanbanov and others added 18 commits October 5, 2026 15:33
packages/svelte is the private @dunky-dev/svelte harness for Svelte 5:
Storybook on port 6009 (@storybook/svelte-vite + Svelte CSF, `pnpm
dev:svelte`), two vitest projects — DOM through svelteTesting() and jsdom,
and SSR through svelte/server, which needs Svelte's server runtime where the
DOM project resolves the browser one — and svelte-check for the typecheck:
tsc can't read .svelte, so the root tsconfig excludes the directory like
packages/solid and `pnpm typecheck` runs svelte-check after tsc.

The Svelte packages build outside tsdown: they ship uncompiled through
@sveltejs/package for the consumer's Svelte to compile (ARCHITECTURE.md
records the exception). `pnpm build` chains `pnpm build:svelte` after
tsdown, so changeset:publish ships them too. svelte-check and the package
build drive TypeScript's classic API, which TypeScript 7 dropped, so the
Svelte packages hold a local typescript@^6 while the root stays on 7.

oxfmt can't parse .svelte yet, so lint-staged runs only oxlint (which reads
the script blocks) on staged .svelte files. knip takes the Svelte stories as
entries; .svelte-kit (svelte-package's scratch copy) is ignored.

The lockfile and the pnpm override that links @dunky.dev/svelte-state-machine
to its in-progress branch (dunky-dev/state-machine#34) stay uncommitted until
the adapter is on npm; its first publish is excluded from the release-age
gate here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dunky.dev/svelte-use-focus-trap and @dunky.dev/svelte-use-scroll-lock: the
Svelte lifecycle around trapFocus and lockScroll, in the Solid hooks' call
shape. Each is a rune module (.svelte.ts) owning one $effect — called while a
component initializes, armed after mount, released on destroy. The focus
trap's target is a getter, so a `$state` element re-arms it and a `bind:this`
variable is read once it has filled; scroll lock takes a MaybeGetter for both
parameters. Options stay per-press getters, read by the util itself.

A rune module rather than an attachment keeps the shape every substrate's
hook shares; the dialog's trap target is a part it already renders. Tests
mirror the React/Solid suites — the trap through a fixture component, the
lock through an $effect.root standing in for one — plus the re-arm on a new
reactive target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dunky.dev/svelte-dialog: the compound Dialog for Svelte 5 — Trigger, Portal,
Backdrop, Viewport, Content, Title, Description, Close hung off the root as
statics (`Component<DialogProps> & Parts`, assembled in index.ts since a
.svelte file holds one component). Same anatomy and behavior contract as the
React and Solid bindings; every DOM sequence comes from @dunky.dev/dom-dialog
and every decision from the core machine — the binding adds Svelte's
lifecycle only.

Svelte-specific decisions the code encodes:
- The connected api is a fresh snapshot per machine change, so the open and
  exit edges key on `$derived(api.open)` and read everything else untracked —
  a presence change must not re-run the focus sequence. The Portal keys on
  `mounted` the same way.
- Portal mounts the layers as their own tree with mount() and
  getAllContexts(): context survives, children land in the target with no
  wrapper element (the containment and exit walks depend on that), a
  container swap re-mounts, nothing renders on the server, and the layer
  re-provides the context with its container for the scroll lock.
- Ids come from the root's $props.id(), with `options.id ?? id` so an
  explicit undefined keeps the fallback.
- `open` + the onOpenChange callback prop; `open` is not $bindable(): a child
  can't tell bound from passed, and an unbound write would be a local
  override — a controlled dialog moving on its own.
- Element access is a bindable `ref` on every element part; initialFocus and
  restoreFocus take an element or a getter, since bind:this fills after
  initialization. The backdrop handle is a plain box read on demand.

Tests: the React/Solid suite ported 1:1 (57 tests, same names and scenarios)
through .svelte fixtures, plus the Svelte corners — id minting, a Title
reporting before the machine starts (child effects run first), presence
churn leaving focus alone, the container swap, bind:ref — and SSR renders of
a closed and an open dialog with stable ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The twelve React/Solid scenarios — standard, alertDialog, longContent,
loginForm, trigger, controlled, scoped, nested, innerPopup, closeOnBack,
nestedCloseOnBack, containment — as Svelte CSF (@storybook/addon-svelte-csf).
Each story is a static `asChild` composition, like the React/Solid `render`
functions, and `exportName` keeps the story ids identical across the three
Storybooks. A story that needs local state (the controlled, alert, scoped,
nested, listbox, and containment ones) is a component in stories/ — Svelte's
unit of state — and the shared styles are strings in stories/styles.ts.

Every scenario ports. Two Svelte notes: the scoped and containment stories
gate their portalled dialog on the container's `bind:this`, which fills after
mount (React's shape, since its ref lands in state the same way); and the
containment story's `[inert]` rule renders through {@html} — a component
<style> would scope it to the story's own elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/templates/packages/svelte/__name__ mirrors the React and Solid
templates in Svelte's shape: a root component that owns the machine and
provides the context, a placeholder Root part spreading
mergeProps(rest, normalize(api.parts.root)) with a bindable ref, the
compound assembled in index.ts, effects + use-<name> over the adapter's
useMachine, a Svelte CSF story, and a fixture-based test.

The scaffold wires the tsdown workspace only with packages whose build is
tsdown, read from the stamped package.json — a Svelte package builds with
@sveltejs/package, so it gets its tsconfig path and nothing more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README's layer diagram and layout list, ARCHITECTURE's substrate list, and
CONTRIBUTING's commands and Storybook list (dev:solid was missing there too)
now name Svelte. ARCHITECTURE gains a "Svelte packages" section under "Who
owns what": why they ship uncompiled through @sveltejs/package instead of
tsdown, what the `svelte` export condition does for consumers, the
alternatives weighed, and the two consequences in the source — relative
imports name the file they load, and a package-local typescript@^6 for the
tools that still need TypeScript's classic API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… rejected

`Component<DialogProps>` defaults its bindings to every prop, so the
compound's annotation quietly re-allowed `bind:open` at the type level — the
very binding the SPEC rules out. `Component<DialogProps, {}, ''>` keeps the
root's own "nothing bindable" type: svelte-check now reports `bind:open` as
binding a non-bindable prop, matching the runtime's dev error. The scaffold
template carries the same annotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Content ran the open edge and the exit window as two effects keyed on
`open`. Svelte tears an effect down right before that same effect re-runs,
so on a reopen mid-exit the open edge moved focus in while the exit window —
the other effect — still held the layer inert; a browser refuses that focus,
and the dialog opens with focus outside it. jsdom doesn't enforce `inert`, so
the shared reopen test passed anyway. One effect now covers both sides of the
edge, its teardown always the previous side's undo; a Svelte-only test pins
the order by recording `inert` at the moment focus moves in.

From review: DialogFocusTarget (the initialFocus/restoreFocus shape) is
exported, the test interaction helpers live once in tests/fixtures, and
ARCHITECTURE states precisely what @sveltejs/package transpiles — `.ts`
modules, while components keep `<script lang="ts">` for Svelte 5's native
TypeScript support.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reopen-mid-exit bug came from two effects keyed on the same edge: each
tears down right before its own re-run, so their teardowns and setups
interleave. packages/svelte/AGENTS.md now states the rule next to the other
effect-ordering notes, for the next primitive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run against @dunky.dev/svelte-state-machine as packed for npm, every dialog
test failed with rune_outside_svelte: from node_modules the adapter's
.svelte.js modules were externalized and executed uncompiled.
vite-plugin-svelte inlines only the Svelte packages the vitest root itself
depends on, and the adapter is a dependency of the packages below the
harness. Inlining it by name keeps the suite green once the lockfile
resolves the adapter from npm instead of the local link, which hid this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dunky.dev/svelte-state-machine publishes first as 0.3.0 (its seed 0.2.0
plus the minor, as with the Solid adapter), so the dialog and the scaffold
template take `^0.3.0` — `^0.1.0` would not have admitted it — and the
release-age exclusion names 0.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ured peer range

The context destructured `createContext()`'s third element, `has` — which
only arrived in Svelte 5.57. Every package declared `svelte ^5.40.0`, so on
5.40–5.56 every <Dialog> threw "hasDialogContext is not a function", on the
client and the server (67 of the 73 tests fail on 5.40.0). The context is
now set/get/hasContext over a private Symbol, which every Svelte 5 has; the
scaffold template follows.

That leaves `$props.id()` (5.20) as the binding's own floor, so the dialog,
both hooks, and the template declare one range,
`svelte >=5.20.0 <5.33.5 || ^5.34.5`: the adapter's, raised to 5.20. Measured
in a scratch install with the packed adapter — the Svelte suites run green
on 5.20.0, 5.33.4, 5.34.5, 5.40.0, and 5.56.10 (5.57.1 here), and 5.19.3
fails to compile the root. The excluded 5.33.5–5.34.4 keep a spread's stale
non-delegated handler (sveltejs/svelte#16180); a new test forwarding a
re-rendered `onfocus` through Trigger fails on 5.34.0 and passes on 5.34.5.
The SPEC gains a "Svelte versions" section; README and changesets state the
range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`bind:this` fills after mount, in tree order, so a Backdrop rendered after
the Viewport was still unbound when Content's open edge registered the layer
— containment then made it inert like the rest of the page, unlike React and
Solid. The Portal's layer component now marks itself bound from its own
effect, which runs after every effect in its tree, and Content's edge waits
for that flag; a Content outside a Portal has no flag and opens at once.
The SPEC drops the ordering rule; a Backdrop in its own, later Portal stays
a documented limit (a separate tree that binds too late).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ined}

The parts defaulted `type` by spread order — `{ type: 'button', ...rest }` —
so a consumer forwarding an unset `type` erased the default, and inside a
form the typeless button submitted it. `rest.type ?? 'button'` keeps the
default for an explicit undefined while an explicit type still wins; the
scaffold template's Root part follows. React and Solid default the same way
and share the defect — left for a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Portal mounts its layers as a tree of their own, and unmount can't wait
for an outro there — so a `transition:` on a portalled part played its intro
on open and never its outro on close. The layers now mount with
`intro: false`: Svelte transitions stay off both ways, and the SPEC's exit
animation note says to animate enter and exit through `data-state` (with
`animated` holding the exit), as on every other substrate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SSR test promised ids "hydration will reuse" but never hydrated. The DOM
project now gets real server markup from a globalSetup that renders
DefaultDialog (`defaultOpen`) once in a Vite SSR graph of its own — that
project resolves Svelte's browser runtime, so it can't render on the server
itself — and a new test hydrates it: the trigger is adopted in place, the
dialog's id and the trigger's aria-controls both equal the server's, and
nothing warns. Comparing against the server value is what makes it bite: a
client-side id would re-render aria-controls into agreement on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…README sample

The dialog SPEC said the package ships `.svelte.js` rune modules — only its
hooks do; the dialog ships `.svelte` components and plain `.js`. Trigger
toggles the dialog, per the core spec, rather than only opening it. The
focus-trap README sample called an undefined `isTopmost`; it now matches the
SPEC's sample (`isTopmostLayer(id)` from dom-overlay). The React and Solid
READMEs carry the same sample — left for a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ound

The template comment said `createContext()`'s `has` arrived "below" the
substrate's floor; 5.57 is far above it (5.20), which is why the template
uses a private key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant