Repository navigation
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The Svelte 5 substrate, opening with Dialog (peer
svelte >=5.20.0 <5.33.5 || ^5.34.5):@dunky.dev/svelte-dialog—<Dialog>withDialog.Trigger/Portal/Backdrop/Viewport/Content/Title/Description/Closehung 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/svelteharness — Storybook on 6009 (pnpm dev:svelte, Svelte CSF), a DOM and an SSR vitest project, svelte-check inpnpm typecheck.scripts/templates/packages/svelte; docs (README, ARCHITECTURE, CONTRIBUTING,packages/svelte/AGENTS.md); changesets.Svelte-specific decisions
@sveltejs/packageemits.svelte/.svelte.js/.js/.d.tsundertypes+svelteexport conditions, so the consumer's Svelte compiles them for client and SSR.pnpm buildchainspnpm build:svelteafter tsdown; publint runs per package. Recorded in ARCHITECTURE ("Svelte packages").$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 withset/get/hasContext, which every Svelte 5 has, notcreateContext.typescript@^6.mount()+getAllContexts(). Context survives, there is no wrapper element, nothing renders on the server, it re-mounts whencontainerchanges, andintro: falsekeeps Svelte transitions off both ways (animate throughdata-state). Content registers the layer once the Portal's whole tree is bound, so render order inside a Portal doesn't matter.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. Useopen+onOpenChange; the root's type binds nothing, so svelte-check rejectsbind:open.$derived(api.open)with untracked bodies. The open edge and the exit window share one effect. Ids come from$props.id()withoptions.id ?? id. Every element part takesbind:ref.typedefaults tobuttoneven when passedundefined.Verified
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.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: landchore/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 shareddom-dialogEscape double-close fix (its own PR), and the React/Solidtype={undefined}default and focus-trap README example.🤖 Generated with Claude Code