Skip to content
Open
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
6 changes: 3 additions & 3 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "dunky-dev/ui" }],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"baseBranch": "origin/main",
"updateInternalDependencies": "patch",
"ignore": []
}
5 changes: 3 additions & 2 deletions .github/actions/ci-setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ runs:
git config user.name github-actions
git config user.email github-actions[bot]@users.noreply.github.com

# No `version:` — pnpm/action-setup reads it from package.json's
# `packageManager` field, keeping a single source of truth. Pinning it here
# too errors with ERR_PNPM_BAD_PM_VERSION when the two disagree.
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.20.0

- name: setup node
uses: actions/setup-node@v4
Expand Down
3 changes: 2 additions & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "all"
"trailingComma": "all",
"ignorePatterns": ["**/dist/**", "scripts/templates/**"]
}
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,5 @@
}
},
"globals": {},
"ignorePatterns": ["**/dist/**"]
"ignorePatterns": ["**/dist/**", "scripts/templates/**"]
}
149 changes: 142 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,148 @@
# Architecture

<!-- Describe the structure of this repo: the layers/packages, what each is
responsible for, and the dependency direction between them. Keep diagrams in
plain ASCII per the rule in AGENTS.md. Delete this placeholder once filled. -->
One behavior, many hosts. Every primitive is modeled once as a framework-free
state machine — its **core** package — and delivered through thin bindings,
one per host environment — the **substrates**. Behavior cannot drift between
hosts because it exists in exactly one place.

## Packages
A substrate is any environment a primitive is delivered to: a framework
(react), another framework (vue, solid), or a different host entirely
(native). Substrates are cheap by design; the expensive thing — the behavior
— is written once.

- `packages/core`_(placeholder)_ the package's public API.
## Layout

## Dependency direction
`packages/` is a grid: one directory per layer, one package per primitive
inside it.

_TODO: document what may import what._
```
packages/
|
+- core/ substrate-free behavior, one package per primitive
| +- dialog/ @dunky.dev/toggle
| +- tooltip/ @dunky.dev/press
| +- ...
|
+- <substrate>/ any future host, same shape
+- dialog/ @dunky.dev/<substrate>-toggle
+- tooltip/ @dunky.dev/<substrate>-toggle
+- ...
```

So a primitive is one package in `core` plus one in each substrate:
`@dunky.dev/<name>` and `@dunky.dev/<substrate>-<name>`.

Each substrate directory is itself a private workspace package
(`@dunky-dev/<substrate>`) that owns the substrate's infrastructure — the dev
harness, framework deps, dev/build scripts. The publishable packages live one
level down. Internal infra uses the `@dunky-dev` scope; published packages use
`@dunky.dev`.

## Layers and dependency direction

```
+-----------------------------------------------------------+
| machine runtime @dunky.dev/state-machine |
| (external dependency) |
+-----------------------------------------------------------+
|
v
+-----------------------------------------------------------+
| core primitive packages/core/<name> |
| @dunky.dev/<name> |
| |
| the behavior: states, transitions, guards, callback |
| ordering — no DOM, no framework, no host assumptions |
+-----------------------------------------------------------+
|
v
+-----------------------------------------------------------+
| substrate binding packages/<substrate>/<name> |
| @dunky.dev/<substrate>-<name> |
| |
| owns the host wiring: element/listener lifecycle, |
| translating host events into machine events, re-syncing |
| options each render so fresh callbacks land |
+-----------------------------------------------------------+
|
v
consumer code
```

The rules, stated as imports:

- A substrate package imports its core counterpart and the machine runtime —
nothing else from this repo.
- A core package imports only the machine runtime.
- 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.

## Inside a primitive

The core package is the source of truth for behavior. The machine itself is
managed by `@dunky.dev/state-machine` — the core package defines the state
graph and the connect surface; the runtime owns state storage, transition
execution, and reactions:

```
packages/core/<name>/
SPEC.md the behavior contract (see Specs)
src/ the source code of the package
tests/ drive the machine with raw events; assert callbacks
```

Two idioms every core package follows (documented in depth per package):

- **Emission mailboxes.** The machine never calls consumer callbacks. An
action writes a fresh token into a context slot; a connector reaction fires
the callback on the reference change. Reaction registration order is the
callback-order contract.
- **The machine never sees props.** Config is seeded into context at build
time; live callbacks flow through the connector.

A substrate package is a binding in the host's native shape:

```
packages/<substrate>/<name>/
SPEC.md host-facing surface; defers behavior to the core SPEC
src/ the binding
tests/ exercise through the host (e.g. render + click)
```

In react that shape is a hook: `use<Name>()` creates the machine once,
attaches/detaches via a ref callback (restart-safe for StrictMode), and
re-syncs options after every render.

A binding adds no behavior — no guards, no ordering, no state of its own. If
a substrate needs a decision made, the decision moves into the core machine
where every other substrate inherits it.

## Specs

`SPEC.md` in the core package is the behavior contract: reference, overview,
anatomy, behavior, states, a11y, constraints, design. Substrate SPECs document
only their own surface (install, usage, API) and link back to the core spec.

Work in that order: spec, then tests, then implementation.

## Scaffolding

`pnpm scaffold <name>` stamps a primitive across every substrate from
`scripts/templates/packages/` and wires the workspace (tsconfig `paths`,
tsdown `workspace`). Adding a substrate to the scaffold is dropping a
directory into the templates — the script discovers it. See
[`scripts/templates/README.md`](./scripts/templates/README.md).

## Who owns what

- The root owns building (tsdown workspace), testing (vitest), lint/format
(oxlint/oxfmt), and releases (changesets) — one config each, no per-package
tooling.
- Each substrate owns its dev harness and framework deps — e.g.
`packages/react/.storybook`, run with `pnpm dev [substrate]`.
- Publishable packages are listed explicitly in `tsdown.config.ts`; a private
package gets a tsconfig path but is never published.

`packages/core` currently also holds a placeholder package
(`@dunky-dev/core`) that keeps the build/test pipeline green; the first real
primitive replaces it.
16 changes: 8 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ pnpm install

## Commands

| Command | What it does |
| ------------------------------- | ---------------------------------------------------------- |
| `pnpm test` | Full test suite, watch mode |
| `pnpm typecheck` | `tsc --noEmit` across the whole workspace |
| `pnpm lint` | `oxlint` |
| `pnpm format` / `format:check` | `oxfmt` |
| `pnpm knip` | Unused exports/dependencies |
| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) |
| Command | What it does |
| ------------------------------ | ---------------------------------------------------------- |
| `pnpm test` | Full test suite, watch mode |
| `pnpm typecheck` | `tsc --noEmit` across the whole workspace |
| `pnpm lint` | `oxlint` |
| `pnpm format` / `format:check` | `oxfmt` |
| `pnpm knip` | Unused exports/dependencies |
| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) |

Target one package or one file instead of the whole workspace:

Expand Down
22 changes: 7 additions & 15 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,13 @@ const config: KnipConfig = {
// their own module — keep those out of the "unused exports" report.
ignoreExportsUsedInFile: true,

workspaces: {
'.': {
// pnpm script names knip mistakes for binaries when it parses
// `pnpm -C <dir> <script>` invocations in package.json / workflows
// (e.g. the `pnpm -C website build` step in deploy.yml).
ignoreBinaries: ['build'],
// When this repo has React packages, install `react`/`@types/react` at the
// root (so tsc/vitest can resolve them as peers) and ignore them here, since
// knip can't attribute a root-level peer to a package import:
// ignoreDependencies: ['react', '@types/react'],
},
// Add per-package overrides here as the workspace grows, e.g. a package that
// re-exports another's surface without importing it directly:
// 'packages/native': { ignoreDependencies: ['@dunky-dev/core'] },
},
// Scaffolding template stubs — not part of the dependency graph until copied
// out by scripts/scaffold.ts.
ignore: ['scripts/templates/**'],

// Add per-package overrides here as the workspace grows, e.g. a package that
// re-exports another's surface without importing it directly:
// workspaces: { 'packages/native/press': { ignoreDependencies: ['@dunky.dev/press'] } }
}

export default config
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
{
"name": "dunky-dev-repo",
"name": "ui",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"test": "vitest",
"test:ci": "vitest run",
"build": "tsdown",
"typecheck": "tsc --noEmit",
"lint": "oxlint --ignore-pattern '.worktrees' .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"knip": "knip",
"scaffold": "node scripts/scaffold.ts",
"dev": "node scripts/sb.js dev",
"build-storybook": "node scripts/sb.js build",
"changeset": "changeset",
"changeset:version": "changeset version",
"changeset:publish": "pnpm build && changeset publish",
"prepare": "husky"
},
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.27.0",
"@types/node": "^22.10.2",
"husky": "^9.1.7",
"knip": "^6.15.0",
"lint-staged": "^17.0.7",
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
"publint": "^0.3.21",
"tsdown": "^0.22.2",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
},
Expand Down
Loading
Loading