Skip to content

feat(solid): the Solid substrate with the Dialog primitive - #44

Draft
ivanbanov wants to merge 5 commits into
mainfrom
solid-dialog
Draft

feat(solid): the Solid substrate with the Dialog primitive#44
ivanbanov wants to merge 5 commits into
mainfrom
solid-dialog

Conversation

@ivanbanov

@ivanbanov ivanbanov commented Aug 18, 2026

Copy link
Copy Markdown
Member

What

The Solid substrate, targeting Solid 2.0 (solid-js + @solidjs/web at ^2.0.0-rc.1):

  • @dunky.dev/solid-dialog — the compound Dialog (Trigger, Portal, Backdrop, Viewport, Content, Title, Description, Close), same anatomy and behavior contract as the React binding, with the connected api as a fine-grained store.
  • @dunky.dev/solid-use-focus-trap / @dunky.dev/solid-use-scroll-lock — the Solid lifecycle wrappers over the framework-free DOM utils.
  • @dunky-dev/solid storybook harness (pnpm dev:solid, port 6008, storybook-solidjs-vite), with the story set mirroring the React one.
  • Scaffold templates under scripts/templates/packages/solidpnpm scaffold <name> now stamps Solid too.
  • Workspace wiring: a second vitest project (vite-plugin-solid lives with the substrate), a Solid tsconfig project (jsxImportSource: "@solidjs/web"), and a per-package tsdown babel config so the dist is compiled by babel-preset-solid.

Solid-specific decisions

  • Bindings merge inside the JSX spread so a machine transition re-translates them. children never rides that spread: a re-evaluated spread re-creates the children it carries, and a child that writes to the machine on mount (Title's presence) loops machine -> spread -> remount -> machine. Every part strips it and renders {props.children} explicitly.
  • Two-phase effects compute eagerly, before refs filluseFocusTrap re-reads its target at apply time, and the backdrop handle shared between sibling parts is a plain mutable box, not a signal (the layer walk reads it in the same settle that mounts the backdrop).
  • Content's initialFocus accepts an element or an accessor resolved at open time — the Solid idiom for a ref variable that fills during render.
  • The dialog context uses a null default so the root's parent-depth lookup stays non-throwing (a Solid 2.0 default-less context throws on unprovided reads).

Blocked on

  • feat(solid): Solid 2 integration state-machine#32 must merge and publish @dunky.dev/solid-state-machine first (including the normalize fix that stringifies boolean aria-* values — Solid 2.0 renders boolean attributes as presence/absence). The lockfile is deliberately not committed until then; local dev uses an uncommitted pnpm-workspace.yaml override that links @dunky.dev/solid-state-machine straight to that branch checkout. The ^0.4.0 dependency ranges assume the fixed-group publish version — adjust if it differs.

Verified

  • 190 tests across both vitest projects (49 dialog tests ported 1:1 from the React binding), typecheck, lint, format, knip (at main's baseline), pnpm build (dist compiled to @solidjs/web templates, publint clean), and the storybook build.

🤖 Generated with Claude Code

packages/solid targets Solid 2.0 (solid-js + @solidjs/web at ^2.0.0-rc.0):
@dunky.dev/solid-dialog plus the lifecycle wrappers
@dunky.dev/solid-use-focus-trap and @dunky.dev/solid-use-scroll-lock, the
@dunky-dev/solid storybook harness (port 6008), scaffold templates, and the
workspace wiring (vitest project split, own tsconfig project with
jsxImportSource @solidjs/web, per-package tsdown babel config compiling the
dist with babel-preset-solid).

Solid-specific decisions the code encodes:
- Bindings merge inside the JSX spread to stay reactive; children never ride
  that spread — a re-evaluated spread re-creates them, and a child that
  writes to the machine on mount (Title's presence) would loop forever.
- Two-phase effects compute eagerly, before refs fill: useFocusTrap re-reads
  its target at apply time, and the shared backdrop handle is a plain box.
- The dialog context uses a null default so the root's parent-depth lookup
  stays non-throwing (a default-less context throws on unprovided reads).

The lockfile is deliberately not committed: @dunky.dev/solid-state-machine
isn't on npm yet (state-machine PR #32); local dev uses a pnpm override
pointing at a packed tarball of that branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ivanbanov added a commit to dunky-dev/state-machine that referenced this pull request Aug 18, 2026
Solid 2.0 renders a boolean attribute as presence/absence, so
aria-expanded={false} disappeared and aria-modal={true} rendered empty.
ARIA states are literal "true"/"false" tokens — serialize them explicitly.
Found by the solid-dialog binding in dunky-dev/ui#44.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ivanbanov added a commit to dunky-dev/state-machine that referenced this pull request Aug 18, 2026
Solid 2.0 renders a boolean attribute as presence/absence, so
aria-expanded={false} disappeared and aria-modal={true} rendered empty.
ARIA states are literal "true"/"false" tokens — serialize them explicitly.
Found by the solid-dialog binding in dunky-dev/ui#44.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ivanbanov and others added 4 commits August 19, 2026 16:28
rc.1 fixes the reconcile store corruption the adapter worked around — the
matching adapter build drops the detour, so the substrate rides the plain
reconcile path. Peer ranges move to ^2.0.0-rc.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cut the comments down to the constraint each one protects — one or two
lines, no narration. Comment-only change on top of the Solid substrate
(#44); no code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refactor(solid): simplify the code comments
@ivanbanov

Copy link
Copy Markdown
Member Author

Status sync (2026-08-21) — this PR has a hard upstream dependency: dunky-dev/state-machine#32.

@dunky.dev/solid-state-machine is still unpublished (npm returns 404; PR #32 is open, unmerged). Because of that, all six CI checks fail at the install step, not on this code:

[ERR_PNPM_OUTDATED_LOCKFILE] Cannot install with "frozen-lockfile"
because pnpm-lock.yaml is not up to date with <ROOT>/packages/solid/package.json

The lockfile stays uncommitted on purpose — it cannot resolve a package that does not exist on the registry. CI goes green only after #32 merges and publishes; nothing in this branch can fix it before then.

Local dev now links the adapter straight from the state-machine solid worktree instead of a packed tarball, via an uncommitted pnpm-workspace.yaml override:

overrides:
  "@dunky.dev/solid-state-machine": "link:<path-to>/state-machine/packages/solid"

Everything passes against that link: build, lint, format, typecheck, changeset status, and 190 tests (53 in the solid vitest project, 137 in the default one). Branch is synced with main and carries #45.

Unblock checklist, once #32 publishes: drop the override, pnpm install, commit the lockfile, and correct the ^0.4.0 ranges in packages/solid/dialog/package.json and the scaffold template if the published version differs.

ivanbanov added a commit to dunky-dev/state-machine that referenced this pull request Aug 21, 2026
* feat(solid): add @dunky.dev/state-machine-solid integration

A first-class Solid bindings target (not a React re-export): useMachine
mirrors the connector snapshot into a createStore via reconcile for
fine-grained updates, runs the lifecycle through onMount/onCleanup, keeps
props fresh with a tracked setProps effect, and runs each ComponentEffect
as its own dep-tracked createEffect. useSelector returns a Solid accessor.
normalize maps the agnostic bindings to Solid DOM props (onInput,
onDblClick, tabindex) and mergeProps applies Solid's class concat +
single-object style merge.

Also split the tsconfig setup into a tsconfig/ folder (base/react/solid/all)
so JSX is a per-project concern, since the repo now has both React and
Solid JSX. Wires Solid into tsdown, the vitest solid project, docs, and a
changeset.

Co-authored-by: Claude <noreply@anthropic.com>

* feat(sandbox): add sandbox/solid cmdk demo

A Solid renderer for the shared ⌘K command-palette machine, mirroring
sandbox/react. Drives the same @sandbox/cmdk-core machine + connect through
the Solid bridge: api is a fine-grained store read directly in JSX, Show/For
for control flow, a createEffect for focus-on-open, and the same cmdkShortcut
ComponentEffect tuple the React sandbox uses. Vite + vite-plugin-solid,
aliasing the workspace TS sources.

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(solid): adopt the substrate-prefix name and manifest conventions

Renames the package to @dunky.dev/solid-state-machine (the convention from
#43) across the manifest, changelog, tsconfig path alias, sandbox, and
changeset. Conforms the manifest to main: ship src/ in the published files
(#53), pin internal workspace deps exact (#59), and add the bindings devDep
the translation contract needs. The changeset now also states the version
policy: solid-js ^1.6 today; Solid 2.0 lands as a separate major once stable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(solid): adopt the bindings translation contract and current target APIs

Brings the Solid target up to main's contract changes:

- normalize maps are vocabulary-typed (HandlerTargets/AttrTargets) and
  exported, with keyed lookups — a typo or unknown key is now a compile
  error (#57/#60). The header carries the non-mechanical translation
  rationale ACCESSIBILITY.md asks for (focusable -> tabindex 0/-1,
  disabled -> aria-disabled per APG).
- the shared describeVocabularyAccounting fixture runs against the solid
  maps — the last target missing the conformance suite.
- ComponentEffects is folded into ComponentEffect; the effects param is a
  plain ComponentEffect[] (#49).
- mergeProps is generic over the consumer's props, cast-free at call
  sites, with the expectTypeOf regression test (#51).
- tests import the published entry (@dunky.dev/solid-state-machine)
  instead of ../src, matching every other target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox): harden the solid demo and align the demo copy

- dedupe solid-js in the solid sandbox's vite config: the package alias
  points inside packages/solid, which carries its own solid-js devDep, so
  version skew would load two runtimes and silently kill reactivity.
- clear the input ref on the Show branch's disposal — the closed palette
  kept a detached <input> alive until the next open.
- sandbox README now tells the four-substrates story: solid in the tree
  and run instructions, and the lifecycle-hook claim rewritten (three
  targets share the React hook; Solid brings its own bridge).
- react demo copy mentions the Solid version, its page title
  disambiguates (cmdk - React), and a user-visible Palette typo is fixed
  in both apps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: add solid to every target enumeration and fix the solid docs page

Solid existed in no doc a contributor or consumer reads first:

- root README target diagram gains the Solid box; AGENTS.md,
  ARCHITECTURE.md, and the ACCESSIBILITY.md hidden fan-out example now
  enumerate all four targets.
- the solid docs page examples were silently broken: string-shorthand
  transitions the core no-ops, and a bare config object instead of
  setup.infer().createMachine — both now mirror the react page. Also:
  Show instead of the React && idiom, the mergeProps import line, the
  stale mapping table replaced with the source link (the 521440e
  convention), cross-target links, and the solid-js version-support note.
- api/effects.mdx no longer states the React-only hooks rule as
  universal and points at the Solid bridge alongside React Native.
- the package README gains the Quick start, the flow diagram, the
  current heading conventions, and the Solid version support section
  (^1.6 now; 2.0 as a separate major once stable, with the migration
  mapped).
- drop a trailing comma in the root tsconfig left by the merge
  resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(react,solid): bind the adapted payloads' preventDefault to its event

normalize() copied the native event's preventDefault onto the
ChangePayload/WheelPayload detached, so calling it threw Illegal
invocation. The payload now carries a closure bound to the event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(solid): run ComponentEffect bodies untracked

A prop the effect body merely read became a hidden dependency and
re-ran the effect. The authored deps list is now the whole re-run
contract, matching the React target's dep array.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(solid): target Solid 2.0 as the first-class peer

Port the bridge to the 2.0 primitives: root-level createStore/reconcile,
two-phase createEffect(compute, apply), onSettled for the lifecycle, and
@solidjs/web for JSX. Peer range is ^2.0.0-rc.0; 1.x is unsupported (2.0
removed the surface the bridge stands on, so the majors are version-split
like the rest of the Solid ecosystem).

Work around a solid-js 2.0.0-rc.0 bug: reconcile corrupts a store node when
it replaces a function-valued property, halting reactivity on the next
tracked read. The bridge reconciles a view holding the previous function
identities and writes the fresh ones through plain draft assignments, all in
one commit. Regression-tested; remove once fixed upstream.

The solid vitest project and tsconfig now live in packages/solid so the root
workspace carries no Solid dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update app.tsx

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>

* Update app.tsx

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>

* Update README.md

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>

* refactor(sandbox): share one stylesheet between the React and Solid demos

Both apps rendered the same command palette with duplicated inline style
objects. Move the shared look into sandbox/shared/styles.css and swap
style={} for class/className — each app now supplies only markup, so a
future third DOM sandbox has one file to add, not another copy of the
same styles.

Trims the Solid docs page of comments that just restated the adjacent
code.

* style(sandbox): rejoin the lead line oxfmt wraps

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(sandbox): move the shared stylesheet into shared/src

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(solid): stringify boolean aria-* values in normalize

Solid 2.0 renders a boolean attribute as presence/absence, so
aria-expanded={false} disappeared and aria-modal={true} rendered empty.
ARIA states are literal "true"/"false" tokens — serialize them explicitly.
Found by the solid-dialog binding in dunky-dev/ui#44.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(solid): shorten the reconcile-workaround comment

* refactor(solid): rebind preventDefault with bind

Same behavior as the arrow-closure wrapper, one line — mirrors the react
normalizer on #64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(solid): target solid-js 2.0.0-rc.1 and drop the reconcile workaround

rc.1 fixes the store corruption when reconcile replaces a function-valued
property, so the function-leaf detour (stableFunctionView /
restoreFunctionLeaves) is gone — the bridge reconciles the snapshot
directly. Peer range moves to ^2.0.0-rc.1; the regression test that guarded
the detour now passes against the plain path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(solid): close the bridge's behavioral gaps found in review

- useSelector: pin the compute-form seed (a selected function comes back
  by identity, never invoked — the createSignal function-arg hazard) and
  that a disposed owner stops evaluating its selector (onCleanup(off)).
- useMachine: pin the stop half of the lifecycle (reactions stop firing
  after unmount), that a ComponentEffect's cleanup runs BEFORE its re-run
  on a dep change, and the negative half of the fine-grained claim (an
  unrelated field change does not wake a reader).
- mergeProps: pin the undefined-consumer early return and the documented
  string-style fall-through to library-wins.

Both regression guards were mutation-verified: reverting the seed to the
value form and dropping onCleanup(off) each fail exactly their new test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(dom): extract the shared DOM translation into @dunky.dev/state-machine-dom

The aria-* attribute projection and the payload adapters were byte-identical
in the React and Solid normalizers, and had already drifted once (the
preventDefault bind fix landed in one but not the other). They now live once
in packages/dom; each target keeps only what genuinely differs — its handler
prop names (onChange/onDoubleClick vs onInput/onDblClick), the focusable →
tabindex casing, and its value serialization (React passes ARIA booleans
through, Solid stringifies them).

Payload construction is pinned once in the dom package's tests; the target
suites keep a wiring proof each (normalize wraps the handler with its
adapter) plus the vocabulary accounting. No consumer-facing API change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
ivanbanov added a commit to dunky-dev/state-machine that referenced this pull request Aug 21, 2026
…der (#63)

* chore: give each package its own tsconfig, drop the root tsconfig folder

tsconfig/react.json actually typechecked everything except solid, while the
solid project already lived in its package — an inconsistent split. Now the
root tsconfig.json is the shared base (it also covers the root config files),
tsconfig.build.json is the tsc -b solution list, and every package owns its
project config. Only react and solid set a JSX flavor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update tsconfig.json

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>

* Update tsconfig.build.json

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>

* Update tsconfig.json

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>

* fix(solid): stringify boolean aria-* values in normalize

Solid 2.0 renders a boolean attribute as presence/absence, so
aria-expanded={false} disappeared and aria-modal={true} rendered empty.
ARIA states are literal "true"/"false" tokens — serialize them explicitly.
Found by the solid-dialog binding in dunky-dev/ui#44.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Signed-off-by: Ivan Banov <ivanbanov@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant