diff --git a/.changeset/svelte-integration.md b/.changeset/svelte-integration.md new file mode 100644 index 0000000..f7e0338 --- /dev/null +++ b/.changeset/svelte-integration.md @@ -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 + + + +``` + +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). diff --git a/.gitignore b/.gitignore index f107741..df63aad 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist .DS_Store *.log .vite +.svelte-kit *.tsbuildinfo .expo diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 740db21..ba56762 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -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"] } diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md index f073b9b..45b50b4 100644 --- a/ACCESSIBILITY.md +++ b/ACCESSIBILITY.md @@ -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) ``` diff --git a/AGENTS.md b/AGENTS.md index 030105b..da4422c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 54cec89..b8a8bfb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,7 +29,7 @@ The host | bridged per target v +------------------------------------------------------------------------+ -| (react, solid, native, opentui, …) | +| (react, solid, svelte, native, opentui, …) | | Runtime-specific bridge | | • lifecycle (build + start/stop) • normalize bindings -> props | | • selector subscription | @@ -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). -**`/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any -future renderer. Each target is the runtime bridge for one environment: the +**`/`** 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. @@ -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//` | 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//` | Hook + normalize per substrate (react, solid, svelte, native, opentui, …) | ## The map @@ -121,7 +121,7 @@ shared/bindings substrate-agnostic event + attr vocabulary shared/utils cross-target, cross-component helpers +-- (mergeProps, composeHandlers) - one substrate (react, solid, native, opentui, …) + 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)) @@ -136,7 +136,8 @@ Three package groups, three jobs: event + attr vocabulary; `shared/utils` owns agnostic helpers (prop merging, handler composition). - **`/`** — _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 @@ -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//*` (`react`, `solid`, `native`, `opentui`, …). | +| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`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. | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eeb6a21..2d0b87f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 | @@ -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 ``` diff --git a/package.json b/package.json index d835914..28ea995 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md new file mode 100644 index 0000000..9ce9d42 --- /dev/null +++ b/packages/svelte/CHANGELOG.md @@ -0,0 +1 @@ +# @dunky.dev/svelte-state-machine 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..c070019 --- /dev/null +++ b/packages/svelte/README.md @@ -0,0 +1,405 @@ +# `@dunky.dev/svelte-state-machine` + +The **Svelte 5 bindings** for [`@dunky.dev/state-machine`](../core/README.md). + +The behavior lives in the core machine — plain TypeScript, no renderer. This +package is the thin Svelte edge that runs it. It does four things: + +1. **`useMachine`** — build the machine once, run its lifecycle, expose its + snapshot reactively, run the component's platform effects. +2. **`useSelector`** — wake a leaf component only when one slice changes. +3. **`normalize`** — translate the machine's agnostic bindings (`onPress`, + `checked`) into real DOM props (`onclick`, `aria-checked`). +4. **`mergeProps`** — merge the consumer's props with the component's. + +``` + core (agnostic) + | + | config + connect() behavior + snapshot -> view api + | + v + this package (Svelte) + | + | useMachine build + start the machine, subscribe + | | + | v + | view.api the connector's snapshot, read lazily + | | + | v + | normalize() DOM / ARIA / events + | + v + +{#if view.api.open} +
I'm a tooltip
+{/if} +``` + +What happened: + +- `useMachine` built the machine and connector **once** (the component's script + runs a single time — the first props seeded the initial state), started it + after mount, and stops it on destroy, before child components tear down. +- Hovering sends plain events; the machine handles the 300ms open delay itself + (`after`) — no `setTimeout` in the component. +- Markup that reads `view.api` re-reads it after a machine or prop change — + each change is a fresh snapshot, and Svelte touches the DOM only where a value + differs. +- `normalize` turned `onPointerEnter` into `onpointerenter` and `describedBy` + into `aria-describedby` — the same `connect` drives React, React Native, or a + terminal through _their_ `normalize`. + +That's the whole model. Everything below is reference. + +--- + +## `useMachine` — the bridge + +Every component calls it with the agnostic pieces, while it initializes: + +```ts +const view = useMachine( + tooltipConfig, // (props) => config — config factory, props seed it ONCE + connectTooltip, // pure connect(): snapshot → view api + tooltipEffects, // the component's substrate effects (ComponentEffect[]) + () => props, // a GETTER for the props, so later changes keep flowing in +) +``` + +It: + +- **builds once** — `machine(createConfig(props))` + `connector(service, +connect, props)`, seeded with a **plain copy** of the props, never the live + `$props()` proxy: the connector value-dedups in `setProps`, and a held proxy + would compare equal to itself and never wake. Later prop changes flow through + `setProps`, never a rebuild. +- **reads the snapshot lazily** — the connector only bumps a version signal, + and reading `view.api` returns the connector's memoized snapshot: `connect()` + runs once a transition settles, at most once per change, and only when + something reads it. `view.api` is that very object — no deep proxy, no copy. + The subscription is created before the template, so a child that sends on + mount (children's effects run before their parent's) still lands in the + parent's `api`. +- **keeps props fresh** — the getter is read through one `$derived`, spread into + `setProps` by an `$effect`, so replacing any top-level prop re-runs it. + `setProps` compares shallowly, as on every target: mutating a prop's value in + place (`items.push(x)`) is not a change — pass a new reference + (`items = [...items, x]`) for anything `connect()` reads. +- **keeps notifications untracked** — core notifies synchronously, so code + that wakes on a notification would otherwise run inside whichever `$effect` + caused it, and a callback that writes state would loop. Reactions and their + callbacks always run untracked, however the machine changed (a send, a + context change, a timer), and so does everything a send runs. +- **serves a consistent `api` mid-send** — a read of `view.api` from inside a + send (a reaction's callback, a selector) gets the last settled snapshot — what + the previous send left — not `connect()` over a transition that set its state + but hasn't run its entry actions yet. Svelte learns of the change only when + the outermost send ends, so anything that read the api mid-send (a `$derived` + slice, a selector) refreshes then. One consequence: `flushSync()` inside a + callback still renders the pre-send UI — to focus newly opened content, + `await tick()` instead. Not covered: transitions that don't start at + `view.machine.send` — an `after` timer, an action's own async send, a watcher + fed by a core effect's `setContext` — and sends issued inside one; a read of + `view.api` there can still see a half-applied transition (a core "settled" + hook would close this). Selectors and callbacks that read the machine instead + are safe on every path. +- **runs the lifecycle** — `service.start()` after mount; `service.stop()` when + the component is destroyed, **before** its child components tear down (React's + order), so a child's cleanup send fires no reactions. 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 automatically. +- **runs the component's substrate effects** — one `$effect` per + `ComponentEffect` entry, re-running only when one of its named prop deps + changes (see below): after `start`, and cleaned up after `stop`. None of it + runs during server rendering. + +Returns `{ api, machine }`: `api` is the reactive view api to spread onto +elements; `machine` is the running service (also handed to `useSelector`). +Read `view.api.x` where you use it — **don't destructure** `api` +(`const { api } = useMachine(…)` captures one snapshot and loses reactivity). + +`view.api` is a fresh object after every machine change, so a few rules keep +reactive code precise: + +- **Read slices.** Derive what an `$effect` needs — + `const open = $derived(view.api.open)` — or use `useSelector`; the effect then + re-runs only when that value changes. +- **Don't read `view.api` in an effect that acts.** An effect that reads + `view.api` and then sends (or calls an action) re-runs itself in a loop. Read + a derived slice instead, or keep the read untracked: + `untrack(() => view.api.toggle())`. +- **Select from the machine.** Selectors and reaction callbacks run inside the + machine's notification: read `machine.context` / `machine.matches(…)` there, + not `view.api`, which they see at its last settled snapshot. + +### Why a props getter + +React hands `useMachine` a fresh `props` value each render; a Svelte component's +script runs once, and its props are reactive bindings. Pass a getter and the +bridge reads the current props inside its effects. `() => props` (the +`$props()` object) is the usual form; a getter that builds its own object — +`() => ({ closeOnEscape, ...rest })`, to apply destructured defaults — works the +same, because effect deps are compared by value, not by which props the getter +read. + +--- + +## `ComponentEffect` — platform effects, next to the component + +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 every other target**, so a component's effects are authored once +and run unchanged on React, Solid, and Svelte. A dialog's Escape-to-close, typed +against its `./dialog` module: + +```ts +import type { ComponentEffect } from '@dunky.dev/svelte-state-machine' +import type { DialogMachine, DialogProps } from './dialog' + +type Effect = ComponentEffect + +export const onEscapeKey: Effect = [ + (machine, props) => { + if (props.closeOnEscape === false) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') machine.send({ type: 'close' }) + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, + ['closeOnEscape'], // re-run only when this prop changes +] +``` + +`useMachine` runs the list — **one `$effect` per entry**. Each named dep is read +through its own `$derived`, so the effect re-runs (cleanup → setup) only when +one of those prop VALUES changes — compared with `Object.is`, as React compares +its dep array — never on unrelated changes; the body runs `untrack`ed, so a prop +it merely reads never becomes a hidden dependency. The deps are prop NAMES — +typed `(keyof Props)[]`, so a typo is a compile error — and the authored list is +the whole re-run contract, identical to every other target. There is no +rules-of-hooks constraint, but the list stays a module constant by convention. + +> The agnostic _decision_ lives in the core component's resolver; only the +> _transport_ (the DOM listener) is here. The machine just receives a plain event. + +--- + +## `useSelector` — fine-grained subscription + +Returns `{ current }`, which updates only when one slice of the machine changes: + +```ts +const open = useSelector(view.machine, () => view.machine.matches('open')) +// a leaf whose machine and value are props: +const isHL = useSelector( + () => machine, + () => machine.context.highlightedValue === value, +) +// read it in markup:
+``` + +`current` holds the selected value as the selector returned it — never a proxy +— and changes only when the selection does. `Object.is` by default; **an +object/array selection MUST pass a custom `isEqual`** so a re-derived equal value +doesn't push 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, +) +``` + +A selector may close over props (`value` above): when one changes, the selector +re-runs, and `isEqual` gates both kinds of change against one baseline. Pass the +machine as a getter when it can change — a prop: the subscription follows it, +and Svelte doesn't warn about capturing a prop's initial value. Like +`useMachine`, call it while a component initializes. + +A selector that throws during a notification — a row its parent is removing on +that very change, reading an index that no longer exists — doesn't throw out of +the sender's `send()`: it is retried in the next flush, which skips a reader +that has been destroyed; a reader that is still mounted surfaces the error +there. + +Reach for `useSelector` when a leaf wants to track one slice of a machine it +doesn't otherwise own — e.g. thousands of rows backed by one machine, each +waking only for its own value (`O(readers)`). + +--- + +## `normalize` — agnostic bindings → DOM props + +`connect` returns substrate-agnostic +[bindings](../core/README.md#connector--the-view-boundary) (`onPress`, `role`). +`normalize` translates them to the props a Svelte element spread expects: + +```ts +const domProps = normalize(view.api.triggerProps) // { onclick, 'aria-expanded', role, tabindex, ... } +``` + +Same vocabulary as the +[React DOM normalizer](../react/README.md#normalize--agnostic-bindings--dom-props), +with Svelte's conventions where the prop name differs: event props are the DOM's +own lowercase attribute names (`onclick`, `onpointerenter`, `onkeydown` — +Svelte reads `onClick` as a `Click` event), `onValueChange` → `oninput`, +`onDoublePress` → `ondblclick`, and `focusable` → `tabindex` (`true → 0`, +`false → -1`). ARIA booleans pass through: Svelte writes `false` as the literal +`"false"` token, on the client and the server. +[Check out the full mapping here](./src/normalize.ts). + +`undefined` values are dropped; any key not in the map (`class`, `data-*`, a +symbol-keyed attachment) passes through unchanged — a non-vocabulary handler +included, so it must already be Svelte's lowercase event prop to fire. +`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` are wrapped so the consumer +receives the agnostic payload built from the native DOM event. + +--- + +## `mergeProps` — consumer props + component props + +When a consumer spreads their own props onto the same element the component +controls, `mergeProps(consumer, library)` merges them the Radix/Ark way, Svelte +flavor: + +```svelte + +{#if selected} +

{selected.current}

+{/if} +{#if childSends} + +{/if} diff --git a/packages/svelte/tests/fixtures/use-selector.svelte b/packages/svelte/tests/fixtures/use-selector.svelte new file mode 100644 index 0000000..9b2e35f --- /dev/null +++ b/packages/svelte/tests/fixtures/use-selector.svelte @@ -0,0 +1,50 @@ + + +{String(selection.current)} +{#if sendOnMount} + +{/if} diff --git a/packages/svelte/tests/merge-props.dom.test.ts b/packages/svelte/tests/merge-props.dom.test.ts new file mode 100644 index 0000000..a5280d3 --- /dev/null +++ b/packages/svelte/tests/merge-props.dom.test.ts @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +/** + * Svelte mergeProps on a real element: the `[consumer, library]` class merge + * leans on Svelte resolving any class shape through clsx (5.16+), and an + * attachment on the library side still attaches. + */ +import { render } from '@testing-library/svelte' +import { createAttachmentKey } from 'svelte/attachments' +import { expect, it, vi } from 'vitest' +import { mergeProps, normalize } from '@dunky.dev/svelte-state-machine' +import Spread from './fixtures/spread.svelte' + +it('renders a merged class of mixed shapes as one class list', () => { + const attrs = mergeProps({ class: ['a', { b: true, off: false }] }, { class: 'c' }) + const { getByTestId } = render(Spread, { attrs }) + expect(getByTestId('target').className).toBe('a b c') +}) + +it('attaches a library attachment through normalize and a merge with consumer props', () => { + const attach = vi.fn() + const library = normalize({ role: 'button', [createAttachmentKey()]: attach } as Record< + string, + unknown + >) + const { getByTestId } = render(Spread, { attrs: mergeProps({ id: 'mine' }, library) }) + expect(attach).toHaveBeenCalledWith(getByTestId('target')) +}) diff --git a/packages/svelte/tests/merge-props.test.ts b/packages/svelte/tests/merge-props.test.ts new file mode 100644 index 0000000..7a75e66 --- /dev/null +++ b/packages/svelte/tests/merge-props.test.ts @@ -0,0 +1,98 @@ +/** + * Svelte mergeProps — consumer + component props, Svelte-style: the agnostic + * rules (handlers compose consumer-first with the defaultPrevented veto, + * library wins) over every `on*` key, Svelte's lowercase event props included, + * plus Svelte's attribute conventions for `class` and `style`. + */ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import type { HTMLButtonAttributes } from 'svelte/elements' +import { mergeProps } from '@dunky.dev/svelte-state-machine' + +describe('svelte mergeProps', () => { + it('composes overlapping lowercase handlers — consumer then library', () => { + const order: string[] = [] + const merged = mergeProps( + { onclick: () => order.push('consumer') }, + { onclick: () => order.push('library') }, + ) + ;(merged.onclick as (e: unknown) => void)({ defaultPrevented: false }) + expect(order).toEqual(['consumer', 'library']) + }) + + it('skips the library handler when the consumer prevents default (veto)', () => { + const consumer = vi.fn() + const library = vi.fn() + const merged = mergeProps({ onkeydown: consumer }, { onkeydown: library }) + ;(merged.onkeydown as (e: unknown) => void)({ defaultPrevented: true }) + expect(consumer).toHaveBeenCalledOnce() + expect(library).not.toHaveBeenCalled() + }) + + it('composes camelCase handlers too', () => { + const consumer = vi.fn() + const library = vi.fn() + const merged = mergeProps({ onClick: consumer }, { onClick: library }) + ;(merged.onClick as (e: unknown) => void)({ defaultPrevented: false }) + expect(consumer).toHaveBeenCalledOnce() + expect(library).toHaveBeenCalledOnce() + }) + + it('library wins on plain attrs', () => { + const out = mergeProps({ id: 'consumer' }, { id: 'lib' }) + expect(out.id).toBe('lib') + }) + + it('joins overlapping string classes into one string', () => { + expect(mergeProps({ class: 'a b' }, { class: 'c' }).class).toBe('a b c') + }) + + it('merges overlapping class of other shapes as [consumer, library]', () => { + const consumerClass = ['a', { b: true }] + expect(mergeProps({ class: consumerClass }, { class: 'c' }).class).toEqual([consumerClass, 'c']) + expect(mergeProps({ class: 'a' }, { class: { c: true } }).class).toEqual(['a', { c: true }]) + }) + + // A nullish handler still wins, as in the agnostic base every target shares. + it("keeps the consumer's class and style over nullish library ones, but not handlers", () => { + const onclick = vi.fn() + const out = mergeProps( + { class: 'mine', style: 'color: red', onclick }, + { class: undefined, style: null, onclick: null }, + ) + expect(out).toMatchObject({ class: 'mine', style: 'color: red', onclick: null }) + }) + + it('carries symbol keys (Svelte attachments) from both sides', () => { + const ours = Symbol('consumer attachment') + const theirs = Symbol('library attachment') + const out: Record = mergeProps({ [ours]: 1, id: 'a' }, { + [theirs]: 2, + } as Record) + expect([out[ours], out[theirs]]).toEqual([1, 2]) + }) + + it('keeps a one-sided class untouched', () => { + const consumerClass = { a: true } + expect(mergeProps({ class: consumerClass }, { id: 'x' }).class).toBe(consumerClass) + expect(mergeProps({ id: 'x' }, { class: 'lib' }).class).toBe('lib') + }) + + it('joins overlapping style strings, library declarations last so they win', () => { + const out = mergeProps({ style: 'color: red' }, { style: 'color: blue' }) + expect(out.style).toBe('color: red; color: blue') + }) + + it('returns the library props as-is when the consumer passes none', () => { + const library = { id: 'lib', onclick: vi.fn() } + expect(mergeProps(undefined, library)).toBe(library) + }) +}) + +describe('svelte mergeProps typing', () => { + it("accepts Svelte's element prop types and preserves them", () => { + const consumer: HTMLButtonAttributes = { class: ['mine', { active: true }], onclick: () => {} } + const out = mergeProps(consumer, { role: 'button' }) + expectTypeOf(out).toExtend() + expect(out.role).toBe('button') + }) +}) diff --git a/packages/svelte/tests/normalize.dom.test.ts b/packages/svelte/tests/normalize.dom.test.ts new file mode 100644 index 0000000..6fec744 --- /dev/null +++ b/packages/svelte/tests/normalize.dom.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +/** + * Svelte normalize on real elements. Svelte 5 attaches a spread `on*` prop as + * a listener for exactly the event named after `on` (case-sensitive), so this + * proves every mapped handler fires for its DOM event, the adapted payloads + * act on the real event, and the attrs land as the ARIA tokens a host reads. + */ +import { render, within } from '@testing-library/svelte' +import { describe, expect, it, vi } from 'vitest' +import type { HandlerKey } from '@dunky.dev/state-machine-bindings' +import { normalize } from '@dunky.dev/svelte-state-machine' +import { HANDLER_MAP } from '../src/normalize' +import Spread from './fixtures/spread.svelte' + +// The DOM event a user action produces for each logical handler — listed +// independently of HANDLER_MAP, so a mis-cased prop can't pass by agreeing +// with itself. +const DOM_EVENT: Record = { + onPress: 'click', + onPointerEnter: 'pointerenter', + onPointerLeave: 'pointerleave', + onPointerMove: 'pointermove', + onPointerDown: 'pointerdown', + onPointerUp: 'pointerup', + onPointerCancel: 'pointercancel', + onFocus: 'focus', + onBlur: 'blur', + onKeyDown: 'keydown', + onKeyUp: 'keyup', + onValueChange: 'input', + onContextMenu: 'contextmenu', + onDoublePress: 'dblclick', + onWheel: 'wheel', + onScroll: 'scroll', + onScrollEnd: 'scrollend', +} + +// Scoped to its own container: one test may mount several. +const mount = (bindings: Record, tag?: string): HTMLElement => + within(render(Spread, { tag, attrs: normalize(bindings) }).container).getByTestId('target') + +describe('svelte normalize — on a real element', () => { + it('fires every mapped handler for its DOM event', () => { + for (const key of Object.keys(HANDLER_MAP) as HandlerKey[]) { + const handler = vi.fn() + mount({ [key]: handler }).dispatchEvent(new Event(DOM_EVENT[key], { bubbles: true })) + expect({ key, calls: handler.mock.calls.length }).toEqual({ key, calls: 1 }) + } + }) + + it("adapted payloads carry the real event's value and a working preventDefault", () => { + let payload: { value?: unknown; preventDefault?: () => void } | undefined + const input = mount({ onValueChange: (p: typeof payload) => (payload = p) }, 'input') + ;(input as HTMLInputElement).value = 'hi' + const event = new Event('input', { bubbles: true, cancelable: true }) + input.dispatchEvent(event) + + expect(payload?.value).toBe('hi') + payload?.preventDefault?.() // a detached native preventDefault throws Illegal invocation + expect(event.defaultPrevented).toBe(true) + }) + + it('writes ARIA states as literal tokens and focusable as tabindex', () => { + const el = mount({ expanded: false, hidden: true, focusable: false }) + expect(el.getAttribute('aria-expanded')).toBe('false') + expect(el.getAttribute('aria-hidden')).toBe('true') + expect(el.getAttribute('tabindex')).toBe('-1') + }) + + // connect() rebuilds its closures on every snapshot, so the spread must + // always call the latest one — delegated (click) and direct (pointerenter). + it('keeps spread handlers current when the bindings change', async () => { + const stale = vi.fn() + const fresh = vi.fn() + const { getByTestId, rerender } = render(Spread, { + attrs: normalize({ onPress: stale, onPointerEnter: stale }), + }) + await rerender({ attrs: normalize({ onPress: fresh, onPointerEnter: fresh }) }) + + const el = getByTestId('target') + el.dispatchEvent(new Event('click', { bubbles: true })) + el.dispatchEvent(new Event('pointerenter')) + expect(stale).not.toHaveBeenCalled() + expect(fresh).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/svelte/tests/normalize.test.ts b/packages/svelte/tests/normalize.test.ts new file mode 100644 index 0000000..c5a3faf --- /dev/null +++ b/packages/svelte/tests/normalize.test.ts @@ -0,0 +1,53 @@ +/** + * Svelte DOM bindings translator — pure-logic tests (no DOM runtime needed). + * + * The `aria-` projection is @dunky.dev/state-machine-dom's, pinned there and + * accounted for here by the shared vocabulary suite. These tests pin what is + * Svelte's own and invisible to the DOM: `focusable` lands on `tabindex`, + * handlers stay unwrapped when they can, and unknown keys pass through. + */ +import { describe, expect, it, vi } from 'vitest' +import { normalize } from '@dunky.dev/svelte-state-machine' +import { ATTR_MAP, HANDLER_MAP } from '../src/normalize' +import { describeVocabularyAccounting } from '../../shared/bindings/tests/fixtures/vocabulary-accounting' + +// Which prop each handler lands on is proven where it matters, on a real +// element (normalize.dom.test.ts); this pins only what the DOM can't see. +describe('svelte normalize — handlers', () => { + it('passes a handler whose payload already matches through unwrapped', () => { + const onContextMenu = vi.fn() + const onDoublePress = vi.fn() + const out = normalize({ onContextMenu, onDoublePress }) + expect(out.oncontextmenu).toBe(onContextMenu) + expect(out.ondblclick).toBe(onDoublePress) + }) +}) + +describe('svelte normalize — attributes', () => { + it('maps focusable to tabindex (lowercase; true → 0, false → -1)', () => { + expect(normalize({ focusable: true })).toEqual({ tabindex: 0 }) + expect(normalize({ focusable: false })).toEqual({ tabindex: -1 }) + }) + + // Only the vocabulary is translated: a handler outside it keeps its name, so + // it must already be Svelte's own lowercase event prop to fire. + it('passes unknown keys through unchanged (data-state, class, a non-vocabulary onMouseDown)', () => { + const onMouseDown = vi.fn() + expect(normalize({ 'data-state': 'open', class: 'x', onMouseDown })).toEqual({ + 'data-state': 'open', + class: 'x', + onMouseDown, + }) + }) + + it('passes symbol keys (Svelte attachments) through', () => { + const attachment = Symbol('attachment') + expect(normalize({ [attachment]: 1 } as Record)).toEqual({ [attachment]: 1 }) + }) + + it('skips undefined values', () => { + expect(normalize({ role: undefined, id: 'x' })).toEqual({ id: 'x' }) + }) +}) + +describeVocabularyAccounting('svelte', normalize, { map: HANDLER_MAP }, { map: ATTR_MAP }) diff --git a/packages/svelte/tests/ssr.test.ts b/packages/svelte/tests/ssr.test.ts new file mode 100644 index 0000000..c914c8b --- /dev/null +++ b/packages/svelte/tests/ssr.test.ts @@ -0,0 +1,66 @@ +/** + * Server rendering (`svelte/server`): `$effect`s never run there, so a + * component renders its initial snapshot without starting the machine or + * running its effects, and spread bindings serialize as the client writes them. + */ +import { render } from 'svelte/server' +import { describe, expect, it, vi } from 'vitest' +import { mergeProps, normalize } from '@dunky.dev/svelte-state-machine' +import Spread from './fixtures/spread.svelte' +import UseMachine from './fixtures/use-machine.svelte' +import UseSelector from './fixtures/use-selector.svelte' +import { makeCounters, type CountersMachine } from './fixtures/counters' +import type { ToggleMachine } from './fixtures/toggle' + +describe('svelte — server rendering', () => { + it('renders the initial snapshot without starting the machine or running effects', () => { + const effect = vi.fn() + let machine: ToggleMachine | undefined + const { body } = render(UseMachine, { + props: { + label: 'ssr', + effects: [[effect, []]], + expose: (view: { machine: ToggleMachine }) => (machine = view.machine), + }, + }) + + const started = vi.fn() + machine!.onStart(started) // fires immediately on a running machine + expect(started).not.toHaveBeenCalled() + expect(effect).not.toHaveBeenCalled() + expect(body).toContain('ssr closed 0') + }) + + it('renders the snapshot a send during script init produced', () => { + const { body } = render(UseMachine, { props: { label: 'ssr', sendAtInit: { type: 'toggle' } } }) + expect(body).toContain('ssr open 1') + }) + + it('renders the selection a send during script init produced', () => { + const { body } = render(UseSelector, { + props: { + machine: makeCounters(), + pick: (m: CountersMachine) => m.context.a, + sendAtInit: { type: 'incA' }, + }, + }) + expect(body).toContain('>1') + }) + + it('serializes spread bindings like the client: ARIA tokens, tabindex, merged class, no handlers', () => { + const attrs = mergeProps( + { class: ['a', { b: true }] }, + normalize({ expanded: false, hidden: true, focusable: false, onPress: () => {}, class: 'c' }), + ) + const { body } = render(Spread, { props: { attrs } }) + for (const attr of [ + 'aria-expanded="false"', + 'aria-hidden="true"', + 'tabindex="-1"', + 'class="a b c"', + ]) { + expect(body).toContain(attr) + } + expect(body).not.toContain('onclick') + }) +}) diff --git a/packages/svelte/tests/use-machine.test.ts b/packages/svelte/tests/use-machine.test.ts new file mode 100644 index 0000000..f8ee396 --- /dev/null +++ b/packages/svelte/tests/use-machine.test.ts @@ -0,0 +1,370 @@ +// @vitest-environment jsdom +// `useMachine` behavioral contract: build once, machine lifecycle, props +// freshness, reactions, dep-tracked ComponentEffects, and the reactive api. +// +// testing-library's rerender swaps one props object, so every prop read +// shares a signal — the way props arrive through a parent's `{...spread}`. +import { fireEvent, render } from '@testing-library/svelte' +import { flushSync } from 'svelte' +import { describe, expect, it, vi } from 'vitest' +import { act as write, createStore } from '@dunky.dev/state-machine' +import type { ComponentEffect } from '@dunky.dev/svelte-state-machine' +import { makeBox } from './fixtures/box.svelte' +import SliceParent from './fixtures/slice-parent.svelte' +import UseMachine from './fixtures/use-machine.svelte' +import { + connectToggle, + createToggleConfig, + type ToggleApi, + type ToggleMachine, + type ToggleProps, + type ToggleView, +} from './fixtures/toggle' + +type Effect = ComponentEffect + +function mount(props: Record = {}) { + let view: ToggleView | undefined + const result = render(UseMachine, { ...props, expose: (v: ToggleView) => (view = v) }) + return { ...result, view: view! } +} + +// A connect that also reports a deep reactive prop, read where connect runs. +const connectWithBox: typeof connectToggle = Object.assign( + (snapshot: Parameters[0]) => ({ + ...connectToggle(snapshot), + label: String((snapshot.props as { box?: { n: number } }).box?.n), + }), + { reactions: connectToggle.reactions }, +) + +// The entry action sets what this connect relies on: open => draft is a string. +type Draft = { draft: string | null } +const createDraftConfig = (() => ({ + initial: 'closed', + context: { count: 0, draft: null }, + states: { + closed: { on: { toggle: { target: 'open' } } }, + open: { entry: write(() => ({ draft: 'ready' })) }, + }, +})) as unknown as typeof createToggleConfig +const connectDraft: typeof connectToggle = Object.assign( + (snapshot: Parameters[0]) => ({ + ...connectToggle(snapshot), + label: snapshot.state === 'open' ? (snapshot.context as unknown as Draft).draft!.trim() : '-', + }), + { reactions: connectToggle.reactions }, +) + +describe('useMachine — lifecycle', () => { + it('renders from the snapshot and updates the DOM on a machine change', async () => { + const { getByTestId } = mount({ label: 'a' }) + expect(getByTestId('toggle').textContent).toBe('a closed 0') + await fireEvent.click(getByTestId('toggle')) + expect(getByTestId('toggle').textContent).toBe('a open 1') + }) + + it('fires the connect reaction (onOpenChange) while mounted, and not after unmount', () => { + const onOpenChange = vi.fn() + const { view, unmount } = mount({ onOpenChange }) + expect(onOpenChange).not.toHaveBeenCalled() // not on subscribe + view.api.toggle() + expect(onOpenChange).toHaveBeenCalledWith(true) + + // Unmount stops the machine, which unhooks the connector's reactions — a + // send may still transition, but the callback must not fire. + unmount() + view.machine.send({ type: 'toggle' }) + expect(onOpenChange).toHaveBeenCalledTimes(1) + }) + + it("starts after mount, before the component effects; stops before their cleanups (React's order)", () => { + const order: string[] = [] + const mounted = () => + document.querySelector('[data-testid="toggle"]') ? 'mounted' : 'unmounted' + const effect: Effect = [ + () => { + order.push(`effect:${mounted()}`) + return () => order.push('cleanup') + }, + [], + ] + const { unmount } = render(UseMachine, { + effects: [effect], + expose: (view: ToggleView) => { + view.machine.onStart(() => order.push(`start:${mounted()}`)) + view.machine.onStop(() => order.push('stop')) + }, + }) + expect(order).toEqual(['start:mounted', 'effect:mounted']) + unmount() + expect(order).toEqual(['start:mounted', 'effect:mounted', 'stop', 'cleanup']) + }) + + // React runs a parent's unmount cleanups before descending into children. + it('stops before child components tear down, so their cleanup sends fire no reactions', () => { + const onOpenChange = vi.fn() + const { unmount } = mount({ onOpenChange, childSends: { destroy: { type: 'toggle' } } }) + unmount() + expect(onOpenChange).not.toHaveBeenCalled() + }) +}) + +describe('useMachine — the api snapshot', () => { + it("is connect()'s own output, not a reactive copy", () => { + const outputs: ToggleApi[] = [] + const connect: typeof connectToggle = Object.assign( + (snapshot: Parameters[0]) => { + const api = connectToggle(snapshot) + outputs.push(api) + return api + }, + { reactions: connectToggle.reactions }, + ) + const { view } = mount({ connect }) + view.api.toggle() + expect(view.api).toBe(outputs.at(-1)) + }) + + it('reflects a send made before mount (a child mount effect runs first)', () => { + const { view, getByTestId } = mount({ childSends: { mount: { type: 'toggle' } } }) + expect(view.api.open).toBe(true) + expect(getByTestId('toggle').textContent).toBe('- open 1') + }) + + it('runs connect() once the transition settles, never mid-transition', () => { + const { view } = mount({ createConfig: createDraftConfig, connect: connectDraft }) + expect(() => view.machine.send({ type: 'toggle' })).not.toThrow() + expect(view.api.label).toBe('ready') + }) + + // A callback or selector runs inside the notification, after setState but + // before the entry action: it gets the last settled api, and a selector + // settles once the send is done. + it('serves reads from inside a send the last settled api, never a half-applied one', () => { + const seen: (string | undefined)[] = [] + const { view, getByTestId } = mount({ + createConfig: createDraftConfig, + connect: connectDraft, + selectLabel: true, + onOpenChange: () => seen.push(view.api.label), + }) + expect(() => view.machine.send({ type: 'toggle' })).not.toThrow() + expect(seen).toEqual(['-']) + flushSync() + expect(getByTestId('selected').textContent).toBe('ready') + }) +}) + +// Core notifies synchronously, so whatever wakes on a notification would +// otherwise run in the tracking scope of the effect that caused it. +describe('useMachine — notifications from inside an effect', () => { + it("never lends a machine listener's reads to the effect that sent", () => { + const box = makeBox() + const inEffect = vi.fn((view: ToggleView) => view.machine.send({ type: 'toggle' })) + render(UseMachine, { + inEffect, + expose: (view: ToggleView) => view.machine.subscribe(() => void box.n), + }) + box.n = 5 // read by the listener, never by the effect + flushSync() + expect(inEffect).toHaveBeenCalledOnce() + }) + + it("never lends a reaction callback's reads or writes to the effect that sent", () => { + const log = makeBox() + const onOpenChange = vi.fn(() => log.n++) // reads and writes state, like a counter + const { view } = mount({ + onOpenChange, + inEffect: (v: ToggleView) => v.machine.send({ type: 'toggle' }), + }) + flushSync() + expect(onOpenChange).toHaveBeenCalledOnce() // no self-invalidating loop + expect(view.api.open).toBe(true) + }) + + // Not every notification is a send through `view.machine`: a core effect can + // set context from an external store, and an action sends with the + // machine's own `send`. + it("never lends a reaction callback's reads to an effect that changed the machine another way", () => { + const box = makeBox() + const external = createStore({ n: 0 }) + const createConfig = (() => ({ + initial: 'closed', + context: { count: 0 }, + states: { + closed: { + effects: [ + ({ setContext }: { setContext: (patch: { count: number }) => void }) => + external.subscribe(({ n }) => setContext({ count: n })), + ], + on: { toggle: { target: 'open' } }, + }, + open: { on: { toggle: { target: 'closed' } } }, + }, + watch: { + count: [({ send }: { send: (e: { type: 'toggle' }) => void }) => send({ type: 'toggle' })], + }, + })) as unknown as typeof createToggleConfig + const onCount = vi.fn(() => box.n) + const connect: typeof connectToggle = Object.assign( + (s: Parameters[0]) => connectToggle(s), + { + reactions: [ + ...(connectToggle.reactions ?? []), + [(m: ToggleMachine) => m.context.count, () => onCount()], + ] as typeof connectToggle.reactions, + }, + ) + const onOpenChange = vi.fn(() => box.n) + const inEffect = vi.fn(() => external.set({ n: 1 })) + const { view } = mount({ createConfig, connect, onOpenChange, inEffect }) + expect([onCount, onOpenChange].map(fn => fn.mock.calls.length)).toEqual([1, 1]) + box.n = 5 // read by both callbacks, never by the effect + flushSync() + expect(inEffect).toHaveBeenCalledOnce() + expect(view.api.open).toBe(true) + }) +}) + +// A send settles before anything learns it happened: reads inside it see the +// last settled api, and whatever they computed is refreshed once it ends. +describe('useMachine — reads during a send', () => { + it('refreshes a slice re-read mid-send once the send settles', () => { + let view: ToggleView | undefined + const { getByTestId } = render(SliceParent, { expose: (v: ToggleView) => (view = v) }) + view!.machine.send({ type: 'toggle' }) // the leaf's notification re-reads the slice + flushSync() + expect(getByTestId('parent').textContent).toBe('true') + expect(getByTestId('leaf').textContent).toBe('open:1') + }) + + it('shows a send the snapshot the previous send in the same tick settled to', () => { + const seen: { open: boolean; count: number }[] = [] + const { view } = mount({ + onOpenChange: () => seen.push({ open: view.api.open, count: view.api.count }), + }) + view.machine.send({ type: 'toggle' }) // → open, count 1 + view.machine.send({ type: 'toggle' }) // → closed, read while in flight + expect(seen).toEqual([ + { open: false, count: 0 }, + { open: true, count: 1 }, + ]) + }) + + it('lets the api catch up after a send that throws', () => { + const createConfig = (() => ({ + initial: 'closed', + context: { count: 0 }, + states: { + closed: { + on: { + toggle: { + target: 'open', + actions: [ + write(($: { context: { count: number } }) => ({ count: $.context.count + 1 })), + () => { + throw new Error('boom') + }, + ], + }, + }, + }, + open: {}, + }, + })) as unknown as typeof createToggleConfig + const { view, getByTestId } = mount({ createConfig }) + expect(() => view.machine.send({ type: 'toggle' })).toThrow('boom') + flushSync() + expect(getByTestId('toggle').textContent).toBe('- closed 1') // the count it did write + }) +}) + +describe('useMachine — props', () => { + it('builds the machine ONCE: state survives prop changes, which reach the snapshot', async () => { + const { view, rerender, getByTestId } = mount({ label: 'a' }) + view.api.toggle() // → open, count 1 + await rerender({ label: 'b' }) // must NOT rebuild/reset state + expect(view.api).toMatchObject({ open: true, count: 1, label: 'b' }) + expect(getByTestId('toggle').textContent).toBe('b open 1') + }) + + // setProps compares shallowly on every target; the docs say to pass a new reference. + it('sees props by reference: a replaced value reaches the api, an in-place mutation does not', async () => { + const box = makeBox() + const { view, rerender } = mount({ connect: connectWithBox, box }) + box.n = 1 + flushSync() + expect(view.api.label).toBe('0') + await rerender({ box: { n: 2 } }) + expect(view.api.label).toBe('2') + }) + + it('value-dedups: a re-render with equal props does not churn the snapshot', async () => { + const { view, rerender } = mount({ label: 'x' }) + const before = view.api + await rerender({ label: 'x' }) // fresh props object, equal values + expect(view.api).toBe(before) + }) +}) + +describe('useMachine — component effects', () => { + // `copy`: the getter builds its own object (destructured defaults), so the + // deps must be tracked by value, not by which props the getter reads. + it.each([false, true])( + 're-runs an effect ONLY when one of its named prop deps changes (copied props: %s)', + async copy => { + const fn = vi.fn(() => () => {}) + const { rerender } = mount({ copy, label: 'a', effects: [[fn, ['label']]] }) + expect(fn).toHaveBeenCalledTimes(1) + + await rerender({ onOpenChange: () => {} }) // non-dep prop changed → no re-run + expect(fn).toHaveBeenCalledTimes(1) + + await rerender({ label: 'b' }) // dep changed → re-run + expect(fn).toHaveBeenCalledTimes(2) + }, + ) + + it('compares deps like React (Object.is): NaN stays equal, -0 differs from 0', async () => { + const fn = vi.fn() + const { rerender } = mount({ delay: NaN, effects: [[fn, ['delay']]] }) + await rerender({ label: 'b' }) // an unrelated change re-reads the NaN dep + expect(fn).toHaveBeenCalledTimes(1) + await rerender({ delay: 0 }) + await rerender({ delay: -0 }) + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('runs the previous cleanup BEFORE re-running on a dep change', async () => { + // A listener-registering effect must tear down before it sets up again, + // or every dep change stacks a listener. + const cleanup = vi.fn() + const fn = vi.fn(() => cleanup) + const { rerender } = mount({ label: 'a', effects: [[fn, ['label']]] }) + await rerender({ label: 'b' }) + expect(cleanup).toHaveBeenCalledOnce() + expect(cleanup.mock.invocationCallOrder[0]!).toBeLessThan(fn.mock.invocationCallOrder[1]!) + }) + + it('does NOT re-run when the effect body reads a prop outside its deps (untracked)', async () => { + // The authored deps list is the whole re-run contract — same as React's + // dep array. A prop the effect merely reads must not become a dependency. + const fn = vi.fn((_machine: ToggleMachine, props: ToggleProps) => void props.label) + const { rerender } = mount({ label: 'a', effects: [[fn, []]] }) + await rerender({ label: 'b' }) + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('receives (machine, props) and can read live machine state', () => { + let seen: { open: boolean; label: string | undefined } | undefined + const effect: Effect = [ + (machine, props) => { + seen = { open: machine.matches('open'), label: props.label } + }, + [], + ] + mount({ label: 'hi', effects: [effect] }) + expect(seen).toEqual({ open: false, label: 'hi' }) + }) +}) diff --git a/packages/svelte/tests/use-selector.test.ts b/packages/svelte/tests/use-selector.test.ts new file mode 100644 index 0000000..cc11df6 --- /dev/null +++ b/packages/svelte/tests/use-selector.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +// `useSelector` contract: a value-deduped `current` (Object.is by default, +// custom isEqual for objects) that follows the machine and the props its +// selector closes over, subscribed for the reader's lifetime. +import { render } from '@testing-library/svelte' +import { flushSync } from 'svelte' +import { describe, expect, it, vi } from 'vitest' +import DropOnChange from './fixtures/drop-on-change.svelte' +import UseSelector from './fixtures/use-selector.svelte' +import { makeCounters, type CountersMachine } from './fixtures/counters' + +// Counts the subscriptions a reader opens on the machine. +function counted(m: CountersMachine) { + const calls = { subscribe: 0 } + const machine = new Proxy(m, { + get: (target, key, receiver) => + key === 'subscribe' + ? (listener: () => void) => (calls.subscribe++, target.subscribe(listener)) + : Reflect.get(target, key, receiver), + }) + return { machine, calls } +} + +// Sends outside an event handler; flush so the reader's effects have run. +const send = (m: CountersMachine, type: 'incA' | 'incB' | 'noop') => { + m.send({ type }) + flushSync() +} + +describe('useSelector', () => { + it('wakes its reader ONLY when the selected value changes (Object.is by default)', () => { + const m = makeCounters() + const onread = vi.fn() + render(UseSelector, { machine: m, pick: (m: CountersMachine) => m.context.a > 0, onread }) + expect(onread).toHaveBeenCalledTimes(1) + + send(m, 'incB') // another slice changed → no wake + send(m, 'noop') // nothing changed → no wake + expect(onread).toHaveBeenCalledTimes(1) + + send(m, 'incA') // false → true → wake + expect(onread).toHaveBeenLastCalledWith(true) + send(m, 'incA') // re-derived, still true → no wake + expect(onread).toHaveBeenCalledTimes(2) + }) + + it('dedups an object selection with isEqual, handing out the selected object itself', () => { + const m = makeCounters() + const selected: { a: number }[] = [] + const onread = vi.fn() + render(UseSelector, { + machine: m, + pick: (m: CountersMachine) => { + const value = { a: m.context.a } + selected.push(value) + return value + }, + isEqual: (x: unknown, y: unknown) => (x as { a: number }).a === (y as { a: number }).a, + onread, + }) + + send(m, 'incB') // a fresh but equal { a } → no wake + expect(onread).toHaveBeenCalledTimes(1) + send(m, 'incA') // { a } changed → wake + expect(onread).toHaveBeenCalledTimes(2) + // Never a reactive copy: the reader sees the objects the selector returned. + for (const [value] of onread.mock.calls) expect(selected).toContain(value) + }) + + it('follows a prop the selector closes over, and compares later changes against it', async () => { + const m = makeCounters() + const { getByTestId, rerender } = render(UseSelector, { + machine: m, + pick: (m: CountersMachine, wanted: number) => m.context.a === wanted, + wanted: 0, + }) + expect(getByTestId('value').textContent).toBe('true') + + await rerender({ wanted: 1 }) // a is 0 → false, with no machine change + expect(getByTestId('value').textContent).toBe('false') + + send(m, 'incA') // a is 1 → true against the NEW prop + expect(getByTestId('value').textContent).toBe('true') + }) + + it('dedups a prop-driven change with isEqual too', async () => { + const m = makeCounters() + const onread = vi.fn() + const { rerender } = render(UseSelector, { + machine: m, + pick: (m: CountersMachine, wanted: number) => ({ a: m.context.a, big: wanted > 100 }), + isEqual: (x: unknown, y: unknown) => JSON.stringify(x) === JSON.stringify(y), + onread, + }) + await rerender({ wanted: 1 }) // a fresh object, equal under isEqual → no wake + expect(onread).toHaveBeenCalledOnce() + }) + + it('follows the machine a getter hands over, waking on it alone', async () => { + const first = makeCounters() + const second = makeCounters() + const pick = (m: CountersMachine) => m.context.a + const { getByTestId, rerender } = render(UseSelector, { machine: first, pick }) + send(second, 'incA') + await rerender({ machine: second }) + expect(getByTestId('value').textContent).toBe('1') // reads the new machine + + send(first, 'incA') // the old machine no longer wakes the reader + send(second, 'incA') + expect(getByTestId('value').textContent).toBe('2') + }) + + it('keeps one subscription while the machine stays the same, whatever else changes', async () => { + const { machine, calls } = counted(makeCounters()) + const { rerender } = render(UseSelector, { machine, pick: (m: CountersMachine) => m.context.a }) + await rerender({ wanted: 1 }) // props share one signal, as through a {...spread} + expect(calls.subscribe).toBe(1) + }) + + // The dropped reader still hears this change's notification, before its + // parent's re-render removes it: neither its stale selector nor its + // isEqual may throw out of send(). + it.each([ + { + throwing: 'selector', + pick: (m: CountersMachine) => { + if (m.context.a > 0) throw new Error('stale index') + return m.context.a + }, + isEqual: undefined, + }, + { + throwing: 'isEqual', + pick: (m: CountersMachine) => (m.context.a === 0 ? { id: 'row-0' } : undefined), + isEqual: (x: unknown, y: unknown) => (x as { id: string }).id === (y as { id: string }).id, + }, + ])( + 'keeps a reader its parent drops on this change out of send() (throwing $throwing)', + ({ pick, isEqual }) => { + const m = makeCounters() + render(DropOnChange, { machine: m, pick, isEqual }) + expect(() => m.send({ type: 'incA' })).not.toThrow() + expect(() => flushSync()).not.toThrow() + }, + ) + + it('gives a dropped reader its last value at teardown, never a stale re-selection', () => { + const m = makeCounters() + const pick = (m: CountersMachine) => { + if (m.context.a > 0) throw new Error('stale index') + return m.context.a + } + const onteardown = vi.fn() + render(DropOnChange, { machine: m, pick, onteardown }) + m.send({ type: 'incA' }) + expect(() => flushSync()).not.toThrow() + expect(onteardown).toHaveBeenCalledWith(0) + }) + + it("surfaces a mounted reader's selector error in the next flush, not in send()", () => { + const m = makeCounters() + const pick = (m: CountersMachine) => { + if (m.context.a === 1) throw new Error('bad state') + return m.context.a + } + const { getByTestId } = render(UseSelector, { machine: m, pick }) + expect(() => m.send({ type: 'incA' })).not.toThrow() + expect(() => flushSync()).toThrow('bad state') + send(m, 'incA') // a later state the selector handles: the reader recovers + expect(getByTestId('value').textContent).toBe('2') + }) + + it('reflects a send made before mount (a child mount effect runs first)', () => { + const m = makeCounters() + const { getByTestId } = render(UseSelector, { + machine: m, + pick: (m: CountersMachine) => m.context.a, + sendOnMount: { type: 'incA' }, + }) + expect(getByTestId('value').textContent).toBe('1') + }) + + it('stops evaluating the selector once unmounted', () => { + const m = makeCounters() + const pick = vi.fn((m: CountersMachine) => m.context.a) + const { unmount } = render(UseSelector, { machine: m, pick }) + unmount() + + const evaluations = pick.mock.calls.length + send(m, 'incA') // unmounted reader → the machine must not re-evaluate it + expect(pick.mock.calls.length).toBe(evaluations) + }) +}) diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json new file mode 100644 index 0000000..b6a5149 --- /dev/null +++ b/packages/svelte/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "tests", "vitest.config.ts"], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/packages/svelte/vitest.config.ts b/packages/svelte/vitest.config.ts new file mode 100644 index 0000000..695d86b --- /dev/null +++ b/packages/svelte/vitest.config.ts @@ -0,0 +1,17 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { svelteTesting } from '@testing-library/svelte/vite' +import { defineConfig } from 'vitest/config' + +// Referenced as a project from the root vitest.config.ts; lives here so the +// root workspace carries no Svelte dependencies. +export default defineConfig({ + // svelteTesting(): Svelte's client build under jsdom + cleanup between tests. + plugins: [svelte({ configFile: false }), svelteTesting()], + test: { + name: 'svelte', + globals: false, + // node by default; DOM tests opt into jsdom per-file via `@vitest-environment`. + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d1998c..e6eea41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 17.0.7 oxfmt: specifier: ^0.52.0 - version: 0.52.0 + version: 0.52.0(svelte@5.57.1) oxlint: specifier: ^1.67.0 version: 1.67.0 @@ -43,7 +43,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.3.2(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) benchmark: dependencies: @@ -227,10 +227,53 @@ importers: version: 2.0.0-rc.1 vite-plugin-solid: specifier: ^3.0.0-next.27 - version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + + packages/svelte: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:* + version: link:../core + '@dunky.dev/state-machine-bindings': + specifier: workspace:* + version: link:../shared/bindings + '@dunky.dev/state-machine-dom': + specifier: workspace:* + version: link:../dom + '@dunky.dev/state-machine-utils': + specifier: workspace:* + version: link:../shared/utils + devDependencies: + '@sveltejs/package': + specifier: ^3.0.0 + version: 3.0.0(svelte@5.57.1)(typescript@6.0.3) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.3.1 + version: 7.3.1(svelte@5.57.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@testing-library/svelte': + specifier: ^5.4.2 + version: 5.4.2(svelte@5.57.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + svelte: + specifier: ^5.57.1 + version: 5.57.1 + svelte-check: + specifier: ^4.7.6 + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.1)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.3.2 + version: 8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) sandbox/native: dependencies: @@ -386,30 +429,55 @@ importers: specifier: ^3.0.0-next.27 version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + sandbox/svelte: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:^ + version: link:../../packages/core + '@dunky.dev/state-machine-bindings': + specifier: workspace:^ + version: link:../../packages/shared/bindings + '@dunky.dev/svelte-state-machine': + specifier: workspace:^ + version: link:../../packages/svelte + '@sandbox/cmdk-core': + specifier: workspace:^ + version: link:../shared + svelte: + specifier: ^5.57.1 + version: 5.57.1 + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^7.3.1 + version: 7.3.1(svelte@5.57.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: + specifier: ^8.3.2 + version: 8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + website: dependencies: '@astrojs/starlight': specifier: ^0.40.0 - version: 0.40.0(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(typescript@6.0.3) + version: 0.40.0(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(typescript@6.0.3) '@dunky.dev/state-machine': specifier: workspace:^ version: link:../packages/core '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(react@19.2.6) + version: 2.0.1(react@19.2.6)(svelte@5.57.1) '@vercel/speed-insights': specifier: ^2.0.0 - version: 2.0.0(react@19.2.6) + version: 2.0.0(react@19.2.6)(svelte@5.57.1) devDependencies: '@astrojs/mdx': specifier: ^6.0.3 - version: 6.0.3(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 6.0.3(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@tailwindcss/postcss': specifier: ^4.3.1 version: 4.3.1 astro: specifier: ^6.4.6 - version: 6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + version: 6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) tailwindcss: specifier: ^4.3.1 version: 4.3.1 @@ -1852,8 +1920,8 @@ packages: '@jridgewell/source-map@0.3.11': resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -2091,6 +2159,9 @@ packages: '@oxc-project/types@0.135.0': resolution: {integrity: sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q==} + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + '@oxc-resolver/binding-android-arm-eabi@11.20.0': resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} cpu: [arm] @@ -2618,6 +2689,12 @@ packages: '@types/react': optional: true + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@rolldown/binding-android-arm64@1.0.2': resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2630,6 +2707,12 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.2': resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2642,6 +2725,12 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.2': resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2654,6 +2743,12 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.2': resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2666,6 +2761,12 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2678,6 +2779,12 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.2': resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2692,6 +2799,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.2': resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2706,6 +2820,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.2': resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2720,6 +2841,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.2': resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2734,6 +2862,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.2': resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2748,6 +2883,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.2': resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2762,6 +2904,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.2': resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2774,6 +2923,12 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.2': resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2796,6 +2951,12 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.2': resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2808,6 +2969,12 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -3027,6 +3194,33 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/package@3.0.0': + resolution: {integrity: sha512-iZXRdFKR6fh6b95TygyYwF1ZyX0V9PwstvgwYfhIcMjgyN5Uegvqpr5Xkmi55uEnw9TW7cmzrfLAX8tbU2ZEvw==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + svelte: ^3.44.0 || ^4.0.0 || ^5.0.0-next.1 + typescript: ^6.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte@7.3.1': + resolution: {integrity: sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + '@tailwindcss/node@4.3.1': resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} @@ -3138,6 +3332,25 @@ packages: '@types/react-dom': optional: true + '@testing-library/svelte-core@1.1.3': + resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} + engines: {node: '>=16'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + + '@testing-library/svelte@5.4.2': + resolution: {integrity: sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==} + engines: {node: '>= 10'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vite: '*' + vitest: '*' + peerDependenciesMeta: + vite: + optional: true + vitest: + optional: true + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -3547,6 +3760,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -3617,6 +3835,10 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -3877,6 +4099,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -4080,6 +4306,9 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + dedent-js@1.0.1: + resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -4124,6 +4353,9 @@ packages: devalue@5.8.1: resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -4285,11 +4517,22 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esrap@2.4.0: + resolution: {integrity: sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} @@ -4822,6 +5065,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -4965,30 +5211,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -4996,6 +5272,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -5003,6 +5286,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -5010,6 +5300,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -5017,22 +5314,45 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -5045,6 +5365,9 @@ packages: resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} engines: {node: '>=22.13.0'} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -5090,6 +5413,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} @@ -5549,6 +5875,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -5819,6 +6150,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -5853,6 +6188,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -5998,6 +6337,10 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -6184,6 +6527,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.62.0: resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -6216,6 +6564,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -6225,6 +6576,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -6455,6 +6811,24 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte2tsx@0.7.61: + resolution: {integrity: sha512-EpQ/+UHITBULeUojx/LLD3uTYasZXcX1BrqlrzfLUnT9vdUhaOWK59a3ryWcFU8L0sY1SP+gRhJ2Beiis98+qw==} + peerDependencies: + svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 + typescript: ^4.9.4 || ^5.0.0 || ^6.0.0 + + svelte@5.57.1: + resolution: {integrity: sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==} + engines: {node: '>=18'} + svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -6923,6 +7297,49 @@ packages: yaml: optional: true + vite@8.3.2: + resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@1.1.3: resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: @@ -7165,6 +7582,9 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + zimmerframe@1.1.5: + resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -7237,13 +7657,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@6.0.3(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@astrojs/mdx@6.0.3(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.0 '@astrojs/markdown-remark': 7.2.0 '@mdx-js/mdx': 3.1.1 acorn: 8.16.0 - astro: 6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + astro: 6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) es-module-lexer: 2.1.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -7267,17 +7687,17 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.4.3 - '@astrojs/starlight@0.40.0(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(typescript@6.0.3)': + '@astrojs/starlight@0.40.0(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(typescript@6.0.3)': dependencies: '@astrojs/markdown-remark': 7.2.0 - '@astrojs/mdx': 6.0.3(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@astrojs/mdx': 6.0.3(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.5.2 '@types/hast': 3.0.4 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - astro-expressive-code: 0.43.1(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + astro: 6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + astro-expressive-code: 0.43.1(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) bcp-47: 2.1.0 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -8889,7 +9309,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -8904,12 +9324,12 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@manypkg/find-root@1.1.0': dependencies: @@ -8933,7 +9353,7 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdx': 2.0.14 - acorn: 8.16.0 + acorn: 8.18.0 collapse-white-space: 2.1.0 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 @@ -8942,7 +9362,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 - recma-jsx: 1.0.1(acorn@8.16.0) + recma-jsx: 1.0.1(acorn@8.18.0) recma-stringify: 1.0.0 rehype-recma: 1.0.0 remark-mdx: 3.1.1 @@ -9124,6 +9544,8 @@ snapshots: '@oxc-project/types@0.135.0': {} + '@oxc-project/types@0.152.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.20.0': optional: true @@ -9549,78 +9971,117 @@ snapshots: optionalDependencies: '@types/react': 19.2.15 + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + '@rolldown/binding-android-arm64@1.0.2': optional: true '@rolldown/binding-android-arm64@1.1.1': optional: true + '@rolldown/binding-android-arm64@1.2.12': + optional: true + '@rolldown/binding-darwin-arm64@1.0.2': optional: true '@rolldown/binding-darwin-arm64@1.1.1': optional: true + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + '@rolldown/binding-darwin-x64@1.0.2': optional: true '@rolldown/binding-darwin-x64@1.1.1': optional: true + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + '@rolldown/binding-freebsd-x64@1.0.2': optional: true '@rolldown/binding-freebsd-x64@1.1.1': optional: true + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.1.1': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.2': optional: true '@rolldown/binding-linux-arm64-gnu@1.1.1': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.2': optional: true '@rolldown/binding-linux-arm64-musl@1.1.1': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.2': optional: true '@rolldown/binding-linux-ppc64-gnu@1.1.1': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.2': optional: true '@rolldown/binding-linux-s390x-gnu@1.1.1': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.2': optional: true '@rolldown/binding-linux-x64-gnu@1.1.1': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.2': optional: true '@rolldown/binding-linux-x64-musl@1.1.1': optional: true + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.2': optional: true '@rolldown/binding-openharmony-arm64@1.1.1': optional: true + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.2': dependencies: '@emnapi/core': 1.10.0 @@ -9641,12 +10102,18 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.1.1': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.2': optional: true '@rolldown/binding-win32-x64-msvc@1.1.1': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + '@rolldown/pluginutils@1.0.1': {} '@rollup/pluginutils@5.4.0(rollup@4.62.0)': @@ -9805,6 +10272,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@solidjs/vite-plugin@3.0.0-next.28(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/core': 7.29.7 + '@dom-expressions/compiler': 0.50.0-next.40 + '@solidjs/web': 2.0.0-rc.1(solid-js@2.0.0-rc.1) + '@types/babel__core': 7.20.5 + babel-preset-solid: 2.0.0-rc.0(@babel/core@7.29.7)(solid-js@2.0.0-rc.1) + merge-anything: 5.1.7 + solid-js: 2.0.0-rc.1 + vite: 8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + '@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1)': dependencies: seroval: 1.5.4 @@ -9813,6 +10295,31 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': + dependencies: + acorn: 8.18.0 + + '@sveltejs/load-config@0.2.3': {} + + '@sveltejs/package@3.0.0(svelte@5.57.1)(typescript@6.0.3)': + dependencies: + '@sveltejs/load-config': 0.2.3 + chokidar: 5.0.0 + semver: 7.8.5 + svelte: 5.57.1 + svelte2tsx: 0.7.61(svelte@5.57.1)(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + deepmerge: 4.3.1 + magic-string: 1.4.2 + obug: 2.1.1 + svelte: 5.57.1 + vite: 8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tailwindcss/node@4.3.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -9903,6 +10410,19 @@ snapshots: '@types/react': 19.2.15 '@types/react-dom': 19.2.3(@types/react@19.2.15) + '@testing-library/svelte-core@1.1.3(svelte@5.57.1)': + dependencies: + svelte: 5.57.1 + + '@testing-library/svelte@5.4.2(svelte@5.57.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/svelte-core': 1.1.3(svelte@5.57.1) + svelte: 5.57.1 + optionalDependencies: + vite: 8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vitest: 4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -10111,13 +10631,15 @@ snapshots: '@urql/core': 5.2.0 wonka: 6.3.6 - '@vercel/analytics@2.0.1(react@19.2.6)': + '@vercel/analytics@2.0.1(react@19.2.6)(svelte@5.57.1)': optionalDependencies: react: 19.2.6 + svelte: 5.57.1 - '@vercel/speed-insights@2.0.0(react@19.2.6)': + '@vercel/speed-insights@2.0.0(react@19.2.6)(svelte@5.57.1)': optionalDependencies: react: 19.2.6 + svelte: 5.57.1 '@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: @@ -10135,21 +10657,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.7(vite@8.3.2(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.3.2(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.7(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.7': dependencies: @@ -10238,12 +10760,14 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.16.0 + acorn: 8.18.0 acorn@8.16.0: {} + acorn@8.18.0: {} + agent-base@7.1.4: {} anser@1.4.10: {} @@ -10297,6 +10821,8 @@ snapshots: dependencies: dequal: 2.0.3 + aria-query@5.3.1: {} + aria-query@5.3.2: {} array-iterate@2.0.1: {} @@ -10315,12 +10841,12 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.43.1(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + astro-expressive-code@0.43.1(astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - astro: 6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + astro: 6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) rehype-expressive-code: 0.43.1 - astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + astro@6.4.8(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.62.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: '@astrojs/compiler': 4.0.0 '@astrojs/internal-helpers': 0.10.0 @@ -10372,8 +10898,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5 vfile: 6.0.3 - vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - vitefu: 1.1.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu: 1.1.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -10682,6 +11208,10 @@ snapshots: chardet@2.1.1: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -10881,6 +11411,8 @@ snapshots: dependencies: character-entities: 2.0.2 + dedent-js@1.0.1: {} + deep-extend@0.6.0: {} deepmerge@4.3.1: {} @@ -10907,6 +11439,8 @@ snapshots: devalue@5.8.1: {} + devalue@5.9.4: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -11007,7 +11541,7 @@ snapshots: esast-util-from-js@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - acorn: 8.16.0 + acorn: 8.18.0 esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 @@ -11081,8 +11615,14 @@ snapshots: escape-string-regexp@5.0.0: {} + esm-env@1.2.2: {} + esprima@4.0.1: {} + esrap@2.4.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.9 @@ -11270,6 +11810,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -11742,6 +12286,10 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -11932,36 +12480,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -11978,6 +12559,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@1.2.4: {} lint-staged@17.0.7: @@ -11997,6 +12594,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 10.0.0 + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -12037,7 +12636,11 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 magicast@0.5.3: dependencies: @@ -12906,8 +13509,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 @@ -13112,6 +13715,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@3.3.19: {} + negotiator@0.6.3: {} negotiator@0.6.4: {} @@ -13278,7 +13883,7 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 - oxfmt@0.52.0: + oxfmt@0.52.0(svelte@5.57.1): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -13301,6 +13906,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.52.0 '@oxfmt/binding-win32-ia32-msvc': 0.52.0 '@oxfmt/binding-win32-x64-msvc': 0.52.0 + svelte: 5.57.1 oxlint@1.67.0: optionalDependencies: @@ -13429,6 +14035,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + pify@4.0.1: {} pirates@4.0.7: {} @@ -13463,6 +14071,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prettier@2.8.8: {} pretty-bytes@5.6.0: {} @@ -13662,6 +14276,8 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readdirp@4.1.2: {} + readdirp@5.0.0: {} recma-build-jsx@1.0.0: @@ -13670,10 +14286,10 @@ snapshots: estree-util-build-jsx: 3.0.1 vfile: 6.0.3 - recma-jsx@1.0.1(acorn@8.16.0): + recma-jsx@1.0.1(acorn@8.18.0): dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) estree-util-to-js: 2.0.0 recma-parse: 1.0.0 recma-stringify: 1.0.0 @@ -13955,6 +14571,27 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.1 '@rolldown/binding-win32-x64-msvc': 1.1.1 + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + rollup@4.62.0: dependencies: '@types/estree': 1.0.9 @@ -14008,10 +14645,14 @@ snapshots: scheduler@0.27.0: {} + scule@1.3.0: {} + semver@6.3.1: {} semver@7.8.1: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -14273,6 +14914,46 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte-check@4.7.6(picomatch@4.0.7)(svelte@5.57.1)(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.2.3 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.57.1 + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + + svelte2tsx@0.7.61(svelte@5.57.1)(typescript@6.0.3): + dependencies: + dedent-js: 1.0.1 + scule: 1.3.0 + svelte: 5.57.1 + typescript: 6.0.3 + + svelte@5.57.1: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.6.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) + '@types/estree': 1.0.9 + acorn: 8.18.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.4 + esm-env: 1.2.2 + esrap: 2.4.0 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.5 + transitivePeerDependencies: + - '@typescript-eslint/types' + svgo@4.0.1: dependencies: commander: 11.1.0 @@ -14307,7 +14988,7 @@ snapshots: terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -14418,8 +15099,7 @@ snapshots: type-fest@0.7.1: {} - typescript@6.0.3: - optional: true + typescript@6.0.3: {} typescript@7.0.2: optionalDependencies: @@ -14606,7 +15286,17 @@ snapshots: - supports-color - vite - vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + vite-plugin-solid@3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@solidjs/vite-plugin': 3.0.0-next.28(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + transitivePeerDependencies: + - '@solidjs/web' + - '@testing-library/jest-dom' + - solid-js + - supports-color + - vite + + vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -14618,18 +15308,34 @@ snapshots: '@types/node': 24.13.2 fsevents: 2.3.3 jiti: 2.7.0 - lightningcss: 1.32.0 + lightningcss: 1.33.0 terser: 5.48.0 tsx: 4.22.4 yaml: 2.9.0 - vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.15 rolldown: 1.0.2 tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.48.0 + tsx: 4.22.4 + yaml: 2.9.0 + + vite@8.3.2(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.19.19 esbuild: 0.28.0 @@ -14639,12 +15345,12 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.2 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.2 @@ -14655,18 +15361,22 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitefu@1.1.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vitefu@1.1.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): optionalDependencies: - vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vitefu@1.1.3(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): optionalDependencies: vite: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - vitest@4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + optionalDependencies: + vite: 8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + + vitest@4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.3.2(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.7(vite@8.3.2(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -14683,7 +15393,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.3.2(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 @@ -14691,10 +15401,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.7(vite@8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -14711,7 +15421,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.3.2(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 @@ -14862,6 +15572,8 @@ snapshots: yocto-queue@1.2.2: {} + zimmerframe@1.1.5: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/sandbox/README.md b/sandbox/README.md index 4f45dc5..44c5793 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -1,7 +1,7 @@ -# cmdk sandbox — one machine, four substrates +# cmdk sandbox — one machine, five substrates A ⌘K **command palette** driven by a single substrate-agnostic state machine, -rendered four ways. The interesting parts — fuzzy filtering, arrow-key +rendered five ways. The interesting parts — fuzzy filtering, arrow-key navigation with wraparound, active-row tracking, selection — all live in `shared/`, the same bytes on every target. Each app only supplies the markup and runs its substrate's `normalize()` over the bindings the shared `connect()` @@ -10,11 +10,12 @@ produces. ``` sandbox/ +-- shared/ @sandbox/cmdk-core — the machine + connect() + commands (NO framework) -| + src/styles.css — the one stylesheet the React and Solid apps share -+-- react/ Vite + React DOM → normalize → onClick / aria-* / role -+-- solid/ Vite + Solid → normalize → onClick / aria-* / tabindex -+-- opentui/ Bun + @opentui/react → normalize → onMouseDown / focusable / cells -+-- native/ Expo + React Native → normalize → onPress / accessibilityState +| + src/styles.css — the one stylesheet the React, Solid, and Svelte apps share ++-- react/ Vite + React DOM -> normalize -> onClick / aria-* / role ++-- solid/ Vite + Solid -> normalize -> onClick / aria-* / tabindex ++-- svelte/ Vite + Svelte 5 -> normalize -> onclick / aria-* / tabindex ++-- opentui/ Bun + @opentui/react -> normalize -> onMouseDown / focusable / cells ++-- native/ Expo + React Native -> normalize -> onPress / accessibilityState ``` The split that makes this work: the **prop translator** (`normalize`) comes from @@ -24,6 +25,7 @@ through a React reconciler, so they share `@dunky.dev/react-state-machine`'s hook — the OpenTUI app is the clearest proof: it imports `useMachine` from the React binding and `normalize` from `@dunky.dev/opentui-state-machine`, exactly the "bring your own framework hook, pair it with the agnostic translator" model. +Solid and Svelte bring their own bridge over the same machine and `connect()`. ## Run @@ -34,6 +36,9 @@ pnpm -C sandbox/react dev # DOM (Solid) — opens at http://localhost:5173 pnpm -C sandbox/solid dev +# DOM (Svelte 5) — opens at http://localhost:5173 +pnpm -C sandbox/svelte dev + # Terminal — needs Bun. Press ⌘K / Ctrl+K to open the palette. pnpm -C sandbox/opentui dev @@ -41,5 +46,5 @@ pnpm -C sandbox/opentui dev pnpm -C sandbox/native start # then press i / a, or scan the QR ``` -All four consume the workspace packages straight from their TypeScript `src/` +All five consume the workspace packages straight from their TypeScript `src/` (Vite alias / Bun workspace / Metro watch-folders) — no build step. diff --git a/sandbox/shared/src/styles.css b/sandbox/shared/src/styles.css index 0586f67..9333b14 100644 --- a/sandbox/shared/src/styles.css +++ b/sandbox/shared/src/styles.css @@ -1,6 +1,6 @@ -/* The one stylesheet the React and Solid apps share — same classes, same - look; each app only supplies markup. The non-DOM targets (opentui, native) - style natively and don't use this. */ +/* The one stylesheet the React, Solid, and Svelte apps share — same classes, + same look; each app only supplies markup. The non-DOM targets (opentui, + native) style natively and don't use this. */ /* Page */ diff --git a/sandbox/svelte/index.html b/sandbox/svelte/index.html new file mode 100644 index 0000000..7fa08b5 --- /dev/null +++ b/sandbox/svelte/index.html @@ -0,0 +1,12 @@ + + + + + + cmdk · Svelte + + +
+ + + diff --git a/sandbox/svelte/package.json b/sandbox/svelte/package.json new file mode 100644 index 0000000..15690b0 --- /dev/null +++ b/sandbox/svelte/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sandbox/cmdk-svelte", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:^", + "@dunky.dev/state-machine-bindings": "workspace:^", + "@dunky.dev/svelte-state-machine": "workspace:^", + "@sandbox/cmdk-core": "workspace:^", + "svelte": "^5.57.1" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "vite": "^8.3.2" + } +} diff --git a/sandbox/svelte/src/app.svelte b/sandbox/svelte/src/app.svelte new file mode 100644 index 0000000..1edc5c1 --- /dev/null +++ b/sandbox/svelte/src/app.svelte @@ -0,0 +1,26 @@ + + +
+

⌘K Command Palette

+
+ +
+

+ One state machine drives this ⌘K palette. +
+ The same machine + connect runs the Svelte, terminal (OpenTUI) and React Native versions +

+

+ Last selected: {last} +

+
diff --git a/sandbox/svelte/src/command-palette.svelte b/sandbox/svelte/src/command-palette.svelte new file mode 100644 index 0000000..be38f57 --- /dev/null +++ b/sandbox/svelte/src/command-palette.svelte @@ -0,0 +1,87 @@ + + +
+ + + {#if view.api.open} + + +
view.api.setOpen(false)}> + +
e.stopPropagation()}> + +
    + {#if view.api.results.length === 0} +
  • No results
  • + {/if} + {#each view.api.results as command, index (command.id)} +
  • + {command.label} + {#if command.hint} + {command.hint} + {/if} +
  • + {/each} +
+
+
+ {/if} +
diff --git a/sandbox/svelte/src/main.ts b/sandbox/svelte/src/main.ts new file mode 100644 index 0000000..25e67d1 --- /dev/null +++ b/sandbox/svelte/src/main.ts @@ -0,0 +1,10 @@ +import { mount } from 'svelte' +import App from './app.svelte' + +// The stylesheet every web sandbox shares. +import '../../shared/src/styles.css' + +const root = document.getElementById('root') +if (!root) throw new Error('missing #root') + +mount(App, { target: root }) diff --git a/sandbox/svelte/tsconfig.json b/sandbox/svelte/tsconfig.json new file mode 100644 index 0000000..00269f5 --- /dev/null +++ b/sandbox/svelte/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["svelte", "node", "vite/client"] + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/sandbox/svelte/vite.config.ts b/sandbox/svelte/vite.config.ts new file mode 100644 index 0000000..8dd65e5 --- /dev/null +++ b/sandbox/svelte/vite.config.ts @@ -0,0 +1,31 @@ +import { resolve } from 'node:path' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { defineConfig } from 'vite' + +// The @dunky.dev/* packages and the shared cmdk core all point at their TS +// `src/index.ts` (no build step). Alias each to its source so Vite transpiles them +// directly — the whole point of the sandbox is to run the workspace source live. +// The svelte() plugin compiles the binding's `.svelte.ts` runes modules here, +// the way a consumer's build compiles the published `.svelte.js` ones. +export default defineConfig({ + plugins: [svelte({ configFile: false })], + resolve: { + // One Svelte runtime for app + aliased packages — two copies means silently + // dead reactivity. + dedupe: ['svelte'], + alias: { + '@dunky.dev/state-machine': resolve(import.meta.dirname, '../../packages/core/src'), + '@dunky.dev/svelte-state-machine': resolve(import.meta.dirname, '../../packages/svelte/src'), + '@dunky.dev/state-machine-utils': resolve( + import.meta.dirname, + '../../packages/shared/utils/src', + ), + '@dunky.dev/state-machine-bindings': resolve( + import.meta.dirname, + '../../packages/shared/bindings/src', + ), + '@dunky.dev/state-machine-dom': resolve(import.meta.dirname, '../../packages/dom/src'), + '@sandbox/cmdk-core': resolve(import.meta.dirname, '../shared/src'), + }, + }, +}) diff --git a/tsconfig.build.json b/tsconfig.build.json index ba5a59a..fdfd321 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,6 +6,7 @@ { "path": "./packages/dom" }, { "path": "./packages/react" }, { "path": "./packages/solid" }, + { "path": "./packages/svelte" }, { "path": "./packages/native" }, { "path": "./packages/opentui" }, { "path": "./packages/shared/utils" }, diff --git a/tsconfig.json b/tsconfig.json index 8436124..62642d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "@dunky.dev/solid-state-machine": ["./packages/solid/src"], "@dunky.dev/native-state-machine": ["./packages/native/src"], "@dunky.dev/opentui-state-machine": ["./packages/opentui/src"], + "@dunky.dev/svelte-state-machine": ["./packages/svelte/src"], "@dunky.dev/state-machine-utils": ["./packages/shared/utils/src"], "@dunky.dev/state-machine-bindings": ["./packages/shared/bindings/src"] }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 937d196..62add05 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -9,6 +9,11 @@ export default defineConfig({ // src/tests dirs, and `include: 'auto'` walks node_modules + grabs the non-published // benchmark/website packages — so for this layout an explicit list is the clean // choice. Keep in sync with the publish set in .changeset/config.json. + // + // `packages/svelte` is absent on purpose: the root `build` script runs its own + // `svelte-package` build. Its runes modules must reach the consumer's Svelte + // compiler as `.svelte.js` files — bundled into a plain `index.js`, the + // compiler would never see them and `$state` would be an undefined global. workspace: [ 'packages/core', 'packages/dom', diff --git a/vitest.config.ts b/vitest.config.ts index 117b2dc..378438a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from 'vitest/config' -// Two projects: the Solid tests need vite-plugin-solid's JSX transform, which -// must not rewrite the React `.tsx` tests. The solid project lives with its -// package so the root carries no Solid dependencies. +// The Solid and Svelte tests need their compiler plugins (vite-plugin-solid's +// JSX transform must not rewrite the React `.tsx` tests), so each runs as its +// own project, living with its package so the root carries no framework +// dependencies. export default defineConfig({ test: { projects: [ @@ -12,10 +13,11 @@ export default defineConfig({ globals: false, environment: 'node', include: ['packages/**/tests/**/*.test.{ts,tsx}'], - exclude: ['**/node_modules/**', '**/dist/**', 'packages/solid/**'], + exclude: ['**/node_modules/**', '**/dist/**', 'packages/solid/**', 'packages/svelte/**'], }, }, './packages/solid/vitest.config.ts', + './packages/svelte/vitest.config.ts', ], }, }) diff --git a/website/astro.config.ts b/website/astro.config.ts index 20f7ef6..82a1f4a 100644 --- a/website/astro.config.ts +++ b/website/astro.config.ts @@ -129,6 +129,7 @@ export default defineConfig({ items: [ { label: 'React', link: 'libs/react' }, { label: 'Solid', link: 'libs/solid' }, + { label: 'Svelte', link: 'libs/svelte' }, { label: 'React Native', link: 'libs/react-native' }, { label: 'OpenTUI', link: 'libs/opentui' }, ], diff --git a/website/src/content/docs/api/effects.mdx b/website/src/content/docs/api/effects.mdx index 6c26bb4..050cc91 100644 --- a/website/src/content/docs/api/effects.mdx +++ b/website/src/content/docs/api/effects.mdx @@ -88,7 +88,7 @@ function Disclosure(props: DisclosureProps) { } ``` -The machine only sees `send({ type: 'close' })`; it has no idea an Escape key exists. On React Native the machine is unchanged; the effect swaps `keydown` for `BackHandler`. On [Solid](/libs/solid) the same tuple runs as its own `createEffect` — no rules-of-hooks constraint, but the list stays a module constant by convention. +The machine only sees `send({ type: 'close' })`; it has no idea an Escape key exists. On React Native the machine is unchanged; the effect swaps `keydown` for `BackHandler`. On [Solid](/libs/solid) the same tuple runs as its own `createEffect`, and on [Svelte](/libs/svelte) as its own `$effect` — no rules-of-hooks constraint, but the list stays a module constant by convention. ## Named effects diff --git a/website/src/content/docs/libs/svelte.mdx b/website/src/content/docs/libs/svelte.mdx new file mode 100644 index 0000000..0eb86c7 --- /dev/null +++ b/website/src/content/docs/libs/svelte.mdx @@ -0,0 +1,200 @@ +--- +title: Svelte +description: Svelte bindings for @dunky.dev/state-machine. +--- + +import Install from '../../../components/install.astro' + + + +Targets Svelte 5 (`svelte` `>=5.16.0 <5.33.5 || ^5.34.5`, tested on 5.57.1). The bridge is written in runes and ships as `.svelte.js` modules that your Svelte build compiles, like a component — use it through Vite with `@sveltejs/vite-plugin-svelte`, or SvelteKit. Vitest picks it up through the same plugin, except in a test harness that reaches it only through other packages' source files: inline it there with `test.server.deps.inline: [/@dunky\.dev\/svelte-/]`, or the runes run uncompiled. + +The Svelte package is a thin edge layer. Behavior lives in the core machine and the component's `connect` function; this package only adapts them to Svelte: lifecycle, reactivity, prop translation, and platform effects. **The machine itself is unchanged** — the same `createDialogConfig` and `connectDialog` run here as on any other target. + +`view.api` is the connector's snapshot, read lazily: `connect()` runs once a transition settles, and only when something reads the result. It is a fresh object after every machine or prop change, which markup re-reads cheaply — Svelte only touches the DOM where a value differs. + +## `useMachine` + +The one bridge. Every component calls it with the four agnostic pieces — the props as a getter — and gets back `{ api, machine }`: + +```svelte + + + +{#if view.api.isOpen} +
Dialog content
+{/if} +``` + +`useMachine` builds the machine and connector **once** (a component's script runs a single time, so the first props seed context; later changes flow through `setProps`, not a rebuild), starts after mount, stops on destroy — before child components tear down, as React does — and exposes the connect output as `view.api`. Call it while the component initializes. Read `view.api` where you use it; do **not** destructure it (`const { api } = useMachine(…)` captures one snapshot and loses reactivity). + +Props go in as a **getter** because a component's script runs once and its props are reactive bindings: the bridge reads them inside its effects. `() => props` is the usual form; a getter that builds its own object, such as `() => ({ closeOnEscape, ...rest })` after destructured defaults, works the same. The bridge compares props shallowly, like every target: to change a value `connect` reads, pass a new reference (`items = [...items, item]`) rather than mutating it in place. + +In an `$effect`, read the slice you need — `const isOpen = $derived(view.api.isOpen)`, or `useSelector` — so it re-runs only when that value changes. Don't read `view.api` in an effect that acts: one that reads it and then sends (or calls an action) re-runs itself in a loop, so read a derived slice there or keep the read untracked (`untrack(() => view.api.toggle())`). What wakes on a notification — reactions, your callbacks — never becomes a dependency of the effect that caused it. Selectors and callbacks run inside that notification, so they should read the machine (`machine.context`, `machine.matches(…)`): during a `view.machine.send`, `view.api` gives them the snapshot the previous send settled to, and Svelte hears of the change only once the send ends — so `flushSync()` in a callback still renders the pre-send UI (`await tick()` to focus newly opened content). Transitions that don't start at `view.machine.send` — an `after` timer, an action's own async send, a watcher fed by a core effect's `setContext` — aren't guarded: a read of `view.api` there can see a half-applied transition. + +### The three imports + +Those three values are where the dialog's behavior actually lives, and none of it is Svelte. You write them once, in a `./dialog` module (its Escape listener lives in `./effects`, below), and they run unchanged on any platform: + +```ts +// dialog.ts: plain functions, no Svelte +import { setup, type Connect, type Machine } from '@dunky.dev/state-machine' +import { onEscapeKey } from './effects' + +export type DialogProps = { + open?: boolean + onOpenChange?: (open: boolean) => void + closeOnEscape?: boolean +} +type State = 'closed' | 'open' +type Context = { closeOnEscape: boolean } +type Event = { type: 'open' } | { type: 'close' } +type Api = { + isOpen: boolean + triggerProps: Record + contentProps: Record +} +export type DialogMachine = Machine + +export const createDialogConfig = (props: DialogProps) => + setup.as().createMachine({ + initial: props.open ? 'open' : 'closed', // props seed the machine ONCE + context: { closeOnEscape: props.closeOnEscape ?? true }, + states: { + closed: { on: { open: { target: 'open' } } }, + open: { on: { close: { target: 'closed' } } }, + }, + }) + +export const connectDialog: Connect = ({ + state, + send, +}) => ({ + isOpen: state === 'open', + triggerProps: { + onPress: () => send({ type: 'open' }), + expanded: state === 'open', + }, + contentProps: { role: 'dialog', modal: true }, +}) + +export const dialogEffects = [onEscapeKey] +``` + +So `useMachine` is the only Svelte-specific piece: `createDialogConfig` is the machine definition, `connectDialog` is the snapshot-to-view-API mapping, and `dialogEffects` are the DOM listeners. Every target imports the **same** `./dialog` module — only the bridge differs. See [Setup](/api/setup) for configs and [Connector](/api/connector) for how `connect` works in depth. + +## `normalize`: bindings → DOM props + +`connect` returns substrate-agnostic bindings (`onPress`, `role`, `describedBy`). `normalize` translates them to the DOM/ARIA props a Svelte element spread expects: + +```ts +normalize(view.api.triggerProps) +// { onclick, 'aria-expanded', role, tabindex, ... } +``` + +The machine binding maps handlers to the DOM's lowercase event attributes (`onPress` → `onclick`, `onValueChange` → `oninput`; Svelte event props are case-sensitive, so `onClick` would listen for a `Click` event), ARIA props (`describedBy` → `aria-describedby`), ARIA state (`checked` → `aria-checked`), and focus (`focusable` → `tabindex`). [Check out the full mapping here](https://github.com/dunky-dev/state-machine/blob/main/packages/svelte/src/normalize.ts). + +`undefined` values are dropped. Unknown keys pass through unchanged. + +## `mergeProps`: consumer + component props + +When a consumer spreads their own props onto the same element the component controls: + +```svelte + + + +``` + +- **Event handlers are chained, consumer-first.** If the consumer calls `e.preventDefault()`, the component's handler is skipped — a clean veto. +- **`class` strings join into one string**; other shapes merge as `[consumer, library]`, which Svelte resolves through clsx, so `class={['card', { active }]}` survives the merge. +- **`style` strings are joined**, the component's declarations last so they win. +- **Everything else: component wins** (`id`, `role`, `aria-*`, even a nullish handler, as on every target) — except a nullish `class`/`style`, which keeps the consumer's. Symbol-keyed attachments from both sides are kept. + +A composed handler calls both sides without `this`: read `event.currentTarget` instead. + +## `useSelector`: fine-grained leaf subscription + +For a leaf component that should only update when one slice of the machine changes (useful when a single machine drives many rows and each row should only wake for its own value). It returns `{ current }` — read `.current` in markup: + +```svelte + + +
{value}
+``` + +`current` updates only when the selected value changes — `Object.is` by default — including when a prop the selector closes over (`value` here) changes. The machine goes in as a getter because it's a prop: the subscription follows it if it changes. For object selections, pass a custom equality function so a re-derived equal object doesn't push a new value: + +```ts +const pos = useSelector( + machine, + () => ({ x: machine.context.x, y: machine.context.y }), + (a, b) => a.x === b.x && a.y === b.y, +) +``` + +`view.api` changes as a whole on every machine change, so `useSelector` is how a leaf tracks one slice of a machine it doesn't otherwise own — e.g. thousands of items backed by one machine, each waking only for its own value. + +## `ComponentEffect`: platform effects + +Some behavior can't live in the machine because it touches the DOM or reads props the machine never sees. Declare each as a `[setup/teardown, depPropNames]` tuple; `useMachine` runs one `$effect` per entry, re-running it (cleanup → setup) when the value of one of its named prop deps changes: + +```ts +// effects.ts +import type { ComponentEffect } from '@dunky.dev/svelte-state-machine' +import type { DialogMachine, DialogProps } from './dialog' + +type Effect = ComponentEffect + +export const onEscapeKey: Effect = [ + (machine, props) => { + if (props.closeOnEscape === false) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') machine.send({ type: 'close' }) + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, + ['closeOnEscape'], // re-run only when this prop changes +] +``` + +The tuple shape is **identical to every other target** — the same `./dialog` effects run everywhere. The deps are prop NAMES (typed `(keyof Props)[]`, so a typo is a compile error); the Svelte bridge tracks exactly those props, by value, and runs the body `untrack`ed, so a prop the effect merely reads never re-runs it. The machine only receives `send({ type: 'close' })`; it has no idea a keyboard event exists. + +See [Effects](/api/effects) for the full mental model of where each type of effect lives.