Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/dom-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@dunky.dev/dom-dialog': minor
'@dunky.dev/react-dialog': patch
'@dunky.dev/solid-dialog': patch
---

New package: `@dunky.dev/dom-dialog`, the framework-free DOM half of the
Dialog. The React and Solid bindings had grown two copies of the same
document-level code — the Escape listener, the ordered focus/stack sequence
around the open edge, the exit window, the session-history guard, the
outside-press gating — differing only in which lifecycle scheduled them. That
duplication is the drift risk the architecture exists to remove, and it would
have been copied a third time for Vue.

Both bindings now contribute only their host's lifecycle:

```ts
// before — the same twenty lines in every DOM substrate
const previous = document.activeElement
const unregister = registerLayer({ id, depth, element: content, modal, backdrop })
const target = initialFocus ?? getInitialFocus(content)
target.focus({ preventScroll: true })
// ...

// after
return openDialogLayer(content, { id, depth, modal, backdrop, initialFocus })
```

The ordering that made those sequences correct — the stack joins before focus
moves in, and releases the layers beneath before focus moves back out — is now
stated and tested in one place rather than re-derived per substrate.

No consumer-visible behavior changes in either binding; this is an internal
extraction. `@dunky.dev/dom-dialog` is published because the bindings depend on
it at runtime, and a substrate outside this repo can build on it directly.

This also establishes `packages/dom/components/` as a layer: a DOM package
scoped to one primitive, which may import that primitive's core package and any
DOM util, but never a framework. `pnpm scaffold <name>` stamps one for every new
primitive.
37 changes: 37 additions & 0 deletions .changeset/solid-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@dunky.dev/solid-dialog': minor
---

New substrate: the Solid binding for `@dunky.dev/dialog`, targeting Solid 2.0
(peers: `solid-js` and `@solidjs/web` at `^2.0.0-rc.1`; 1.x is unsupported —
the binding stands on 2.0's primitives). The same compound anatomy and
behavior contract as the React binding — one core machine, a new host —
delivered in Solid's native shape: the connected api is a fine-grained store,
so a machine transition updates exactly the bindings that changed, and the
core options are plain reactive props (per the controlled contract a
dismissal on a controlled dialog reports nothing — decide it at its source in
the dismissal callbacks, which carry `preventDefault()` for the veto).

```tsx
import { Dialog } from '@dunky.dev/solid-dialog'
;<Dialog open={open()} onOpenChange={setOpen} onEscapeKeyDown={() => setOpen(false)}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog>
```

`Content`'s `initialFocus` accepts an element or an accessor resolved at open
time — the Solid idiom for a ref variable that fills during render, so
`initialFocus={() => cancelButton}` works. Everything else follows the core
spec: layer stack with assistive-tech containment, focus trap with Close as
the cycle's last stop, scroll lock (scoped to the Portal container when
given), exit animations through `data-state="closing"`, and `closeOnBack`.
14 changes: 14 additions & 0 deletions .changeset/solid-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@dunky.dev/solid-use-focus-trap': minor
'@dunky.dev/solid-use-scroll-lock': minor
---

New substrate: the Solid lifecycle wrappers over the framework-free DOM utils,
mirroring the React hooks one-for-one and targeting Solid 2.0 (peer
`solid-js@^2.0.0-rc.1`). `useFocusTrap(target, options?)` takes an accessor
for the container (a plain ref variable fills during render, so the trap arms
on mount and re-arms when a reactive accessor yields a new element);
`useScrollLock(locked?, target?)` accepts a `MaybeAccessor` for both
parameters so the lock tracks reactive state. The behavior itself lives in
`@dunky.dev/dom-focus-trap` and `@dunky.dev/dom-scroll-lock` — these
primitives own only the lifecycle.
18 changes: 18 additions & 0 deletions .changeset/state-machine-0-3-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@dunky.dev/controllable': patch
'@dunky.dev/dialog': patch
'@dunky.dev/native-dialog': patch
'@dunky.dev/react-dialog': patch
---

Update the state-machine packages to the 2026-08-22 release: runtime `0.3.3`,
bindings `0.4.1`, utils `0.4.0`, and the React (`0.3.4`), Solid (`0.3.0`), and
native (`0.4.0`) adapters.

Every range moves together on purpose. The published adapters pin the runtime
exactly (`@dunky.dev/state-machine: 0.3.3`), so a package left on an older
caret would have pulled a second physical copy of the runtime into a consumer's
install — the dependency diamond `ARCHITECTURE.md` warns about, where anything
identity-sensitive (a singleton, a `WeakMap`, module-level state) silently stops
agreeing across the two copies. `@dunky.dev/controllable` was the oldest
offender, still on `^0.1.0`; the tree now resolves to a single runtime.
29 changes: 19 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ editing files in that scope — it overrides anything here for that scope

## Scopes

| Scope | Path | What it is |
| ---------- | ------------------------- | -------------------------------------------------------- |
| Core | `packages/core/**` | Framework-free state machines, one package per primitive |
| DOM | `packages/dom/**` | Framework-free DOM utilities, one package per util |
| Substrates | `packages/<substrate>/**` | Thin host bindings (e.g. `packages/react`) |
| Scope | Path | What it is |
| ---------- | ---------------------------- | --------------------------------------------------------- |
| Core | `packages/core/**` | Framework-free state machines, one package per primitive |
| DOM utils | `packages/dom/utils/**` | Framework-free DOM utilities, one package per util |
| DOM parts | `packages/dom/components/**` | Framework-free DOM half of one primitive, shared by hosts |
| Substrates | `packages/<substrate>/**` | Thin host bindings (e.g. `packages/react`) |

Some changes are cross-scope. Check what else your change touches before
calling it done.
Expand All @@ -48,13 +49,21 @@ architecture:

- **Dependency direction is one-way.** A substrate package imports its core
counterpart, its substrate's state-machine adapter
(`@dunky.dev/<substrate>-state-machine`), its own hooks, and the DOM utils —
nothing else from this repo. A core package imports only the state-machine
runtime, the agnostic bindings vocabulary
(`@dunky.dev/<substrate>-state-machine`), its own hooks, the DOM utils, and —
if it's a DOM host — its primitive's DOM component package
(`@dunky.dev/dom-<name>`). Nothing else from this repo. A core package
imports only the state-machine runtime, the agnostic bindings vocabulary
(`@dunky.dev/state-machine` + `@dunky.dev/state-machine-bindings`), and the
machine utils under `core/utils`. A machine util imports only the runtime;
a DOM util imports nothing from this repo; a substrate hook imports only
the DOM util it wraps.
a DOM util imports nothing from this repo; a DOM component imports its core
counterpart and the DOM utils, never a framework; a substrate hook imports
only the DOM util it wraps.
- **DOM behavior is written once too.** Logic that is DOM-specific but not
framework-specific — a document listener, an ordered focus/stack sequence —
belongs in `dom/components/<name>`, not copied across substrates. A DOM
binding contributes its host's lifecycle and nothing else. Before writing an
effect body in a substrate, ask whether the other DOM substrates would write
the same one.
- **Primitives are independent.** No cross-imports between primitives. If two
need to share logic, that's a design decision — a new package — never a
cross-import.
Expand Down
27 changes: 22 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ packages/
|
+- dom/
| +- utils/ framework-free DOM utilities, one package per util
| +- focus-trap/ @dunky.dev/dom-focus-trap
| +- scroll-lock/ @dunky.dev/dom-scroll-lock
| | +- focus-trap/ @dunky.dev/dom-focus-trap
| | +- scroll-lock/ @dunky.dev/dom-scroll-lock
| | +- ...
| +- components/ framework-free DOM half of a primitive
| +- dialog/ @dunky.dev/dom-dialog
| +- ...
|
+- <substrate>/ any future host, same shape
Expand Down Expand Up @@ -57,6 +60,15 @@ scroll locking — lives once as a framework-free util under `dom/utils/`; each
substrate wraps what needs a lifecycle in a thin hook under its own `hooks/`
folder. A new substrate reuses all of it and only writes the wrappers.

DOM logic that belongs to **one** primitive but to **every** DOM substrate —
the dialog's Escape listener, the ordered sequence around its open and exit
edges — lives under `dom/components/` instead. A util is primitive-agnostic
and imports nothing from the repo; a component package is the opposite, and
may import the primitive's core package and any DOM util. Both are equally
framework-free. The split matters as substrates multiply: React, Solid, and
Vue differ in how they schedule an effect, not in what the effect does, so the
what is written once and each binding contributes only its lifecycle.

Machine logic that several primitives need — the controlled/uncontrolled
machinery (`@dunky.dev/controllable`) — lives the same way under
`core/utils/`: substrate-free helpers a core machine composes into its
Expand Down Expand Up @@ -103,12 +115,15 @@ level down. Internal infra uses the `@dunky-dev` scope; published packages use
The rules, stated as imports:

- A substrate package imports its core counterpart, its substrate's
state-machine adapter, its own hooks, and the DOM utils — nothing else from
this repo.
state-machine adapter, its own hooks, the DOM utils, and — for a DOM
substrate — its primitive's `dom/components` package. Nothing else from this
repo.
- A core package imports only the state-machine runtime and the agnostic
bindings vocabulary.
- A DOM util imports nothing from this repo; a substrate hook imports only the
DOM util it wraps.
- A `dom/components` package imports its core counterpart and the DOM utils —
never a framework, and never another primitive.
- Primitives are independent of each other. If two need to share logic, that
sharing is a design decision (a new package), never a cross-import.

Expand Down Expand Up @@ -163,7 +178,9 @@ packages/<substrate>/<name>/ @dunky.dev/<substrate>-<name>
context.ts compound context: the root provides { api, machine }
use-<name>.ts the machine owner: wraps the adapter's useMachine
(create once, option re-sync, effects), mints ids
effects.ts ComponentEffects: prop-driven / document-level work
effects.ts ComponentEffects: prop-driven / document-level work —
only where the host has no shared package to take them
from (a DOM substrate uses @dunky.dev/dom-<name>)
<name>.tsx root + parts: wires behavior onto host elements, via
the adapter's normalize + mergeProps
tests/
Expand Down
11 changes: 11 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ const config: KnipConfig = {
'packages/react/*': {
entry: ['stories/*.stories.tsx'],
},
// knip's storybook plugin doesn't know the community solid framework;
// jest-dom is loaded via a setup file vite-plugin-solid injects.
'packages/solid': {
entry: ['.storybook/main.ts', '.storybook/manager.ts'],
ignoreDependencies: ['@testing-library/jest-dom'],
},
'packages/solid/*': {
entry: ['stories/*.stories.tsx'],
// The babel presets are referenced as strings in tsdown.config.ts.
ignoreDependencies: ['babel-preset-solid', '@babel/preset-typescript'],
},
},
}

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
"test:native": "pnpm --filter @dunky-dev/native test",
"test:ci": "vitest run && pnpm test:native",
"build": "tsdown",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc --noEmit -p packages/solid",
"lint": "oxlint --ignore-pattern '.worktrees' .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"knip": "knip",
"scaffold": "node scripts/scaffold.ts",
"dev": "pnpm dev:react",
"dev:react": "pnpm --filter @dunky-dev/react dev",
"dev:solid": "pnpm --filter @dunky-dev/solid dev",
"dev:expo": "pnpm --filter @dunky-dev/native dev",
"dev:ios": "pnpm --filter @dunky-dev/native ondevice:ios",
"dev:android": "pnpm --filter @dunky-dev/native ondevice:android",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/dialog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
},
"dependencies": {
"@dunky.dev/controllable": "workspace:*",
"@dunky.dev/state-machine": "^0.3.2",
"@dunky.dev/state-machine-bindings": "^0.3.2"
"@dunky.dev/state-machine": "^0.3.3",
"@dunky.dev/state-machine-bindings": "^0.4.1"
}
}
2 changes: 1 addition & 1 deletion packages/core/utils/controllable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@
"build": "tsdown"
},
"dependencies": {
"@dunky.dev/state-machine": "^0.1.0"
"@dunky.dev/state-machine": "^0.3.3"
}
}
Loading
Loading