diff --git a/.changeset/config.json b/.changeset/config.json index fce1c26..87d24a6 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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": [] } diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 0042f61..273c33c 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -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 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index c5d62d5..c6c4abb 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -8,5 +8,6 @@ "useTabs": false, "semi": false, "singleQuote": true, - "trailingComma": "all" + "trailingComma": "all", + "ignorePatterns": ["**/dist/**", "scripts/templates/**"] } diff --git a/.oxlintrc.json b/.oxlintrc.json index bcdd7b5..3a5e3c3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -50,5 +50,5 @@ } }, "globals": {}, - "ignorePatterns": ["**/dist/**"] + "ignorePatterns": ["**/dist/**", "scripts/templates/**"] } diff --git a/AGENTS.md b/AGENTS.md index d268c43..e39fc47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,77 +1,147 @@ -# Agent + contributor guide +# Agents -The working contract for anyone (human or agent) modifying code in this -repo. This file is the canonical entry point — read it first, every time. +The working contract for anyone — human or agent — modifying code in this +repo. This file is the canonical entry point: read it first, every time. + + ## Preflight Before touching any code, read these in order: -1. `AGENTS.md` (this file) — rules, boundaries, workflow. -2. `README.md` — what this project is and how to run it. -3. `ARCHITECTURE.md` — the structure and where things live. +1. `AGENTS.md` (this file) — rules, boundaries, the flow. +2. `README.md` — what this repo is and where things are. +3. `ARCHITECTURE.md` — the deep reference: stack, structure, testing, releases. + +If a nested `AGENTS.md` exists next to a `package.json`, read it before +editing files in that scope — it overrides anything here for that scope +(see [Agents](#agents-1)). + +## Scopes -If a per-package `AGENTS.md` exists alongside a `package.json`, read it -before editing files in that package — it overrides anything here for -that scope. +| Scope | Path | What it is | +| ---------- | ------------------------- | -------------------------------------------------------- | +| Core | `packages/core/**` | Framework-free state machines, one package per primitive | +| Substrates | `packages//**` | Thin host bindings (e.g. `packages/react`) | + +Some changes are cross-scope. Check what else your change touches before +calling it done. ## Boundaries - +These are invariants, not preferences. Violating them breaks the +architecture: + +- **Dependency direction is one-way.** A substrate package imports its core + counterpart and `@dunky.dev/state-machine` — nothing else from this repo. A + core package imports only `@dunky.dev/state-machine`. +- **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. +- **The machine never sees props.** Config is seeded into context at build + time; live callbacks flow through the connector, not into the machine. +- **Emission mailboxes, not direct calls.** The machine never calls a + consumer callback directly. 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. +- **A binding adds no behavior.** No guards, ordering, or state of its own — + if a substrate needs a decision made, the decision moves into the core + machine so every other substrate inherits it. +- **Don't re-test the runtime.** `@dunky.dev/state-machine` owns transition + execution and reaction firing; a primitive's tests cover its own state + graph, guards, and callback contract — not the runtime's mechanics. ## Workflow -Before finishing any change: +`SPEC -> TEST -> IMPLEMENT -> RECONCILE` -- **Check affected packages.** A change in one package may need follow-up in - others. Always check what else the change touches. -- **Follow the architecture.** Read `ARCHITECTURE.md` before making structural - decisions. When something doesn't fit cleanly, flag it and suggest a better - approach rather than forcing it. -- **Suggest improvements proactively.** If you notice a cleaner design, a missing - test, or a pattern inconsistency while working, say so — don't just do the minimum. -- **Use available skills.** Before reviewing code use the `/code-review` skill. - Check what's available. +Keep an eye on what's been done and what's going on so you don't make a +mess: know the state before you change it, and check your work still +fits after. -## Diagrams in docs +### SPEC -Draw ASCII diagrams (boxes, trees, flows) with plain `|`, `-`, and `+` -only — never Unicode box-drawing characters (`┌ ┐ └ ┘ ─ │ ├ ┼ ▼` …). -Use `|` for verticals, `-` for horizontals, `+` for every corner and -junction, and a plain `v` / `^` / `>` / `<` for arrowheads. The -box-drawing glyphs render inconsistently across fonts, terminals, and -GitHub, and are awkward to edit; the ASCII set is portable and diffs -cleanly. This applies to every `.md` in the repo. +Describes the package's intent: the problem it solves, its expected +behavior, and the constraints and edge cases that define its scope. The +spec is a description of intent, not a checklist — the reconcile step +verifies the implementation against it, so state what the package is +meant to do rather than enumerating fields. Capture what carries design +and API meaning: the consumer-facing API, the behavior contract, and any +UX or reference material that anchors it. Its home is the package's +`SPEC.md`. + +If the package has no `SPEC.md` yet, ask before creating one — don't +add it unprompted. + +A design decision lives in one `SPEC.md` — the package it's about. +Don't copy it into every package it touches. When a decision affects +others, just check their specs don't now contradict it; only edit +another package's `SPEC.md` if it genuinely conflicts (and prefer a +short cross-reference over restating the decision). + +### TEST + +Write behavior tests first. Tests are the executable spec: they capture +what the code is meant to do before the code exists. + +### IMPLEMENT + +Write the code that faithfully realizes the SPEC and the behavior the +TEST phase captures, within the boundaries and the architecture. The +implementation must cover the intent, not merely satisfy the assertions. +Match the existing code patterns rather than inventing structure +whenever possible, otherwise open it up for discussion. + +### RECONCILE + +Check whether the SPEC still describes the code: loop back or ship it. +Before shipping: tests, lint, and type-check pass, and the change is +verified across all scopes. If something's off, loop back to SPEC or +TEST; if not, ship it! ## Code +### Naming + +Descriptive names everywhere. Short names are fine for local variables +with a tight, obvious scope. + +### Comments + +If the code says what it does, the comment is noise. Write comments to +explain _why_: a hidden constraint, a subtle invariant, a workaround, a +hard decision. Keep them short — over-explaining is its own kind of +noise. Don't restate what the code does or repeat a rule already stated +in [`ARCHITECTURE.md`](./ARCHITECTURE.md) — that's implicit and redundant. + ### Performance -- **Performance is a constraint, not a feature.** Prefer mutation over allocation on hot paths. - Avoid spreading objects, chaining array methods, or allocating closures inside loops. -- **Descriptive names everywhere.** Short names are fine for - local variables with a tight, obvious scope. -- **Comments.** If the code says what it does, the comment is - noise. Write comments to explain _why_: a hidden constraint, a subtle invariant, a - workaround, a hard decision. Keep them short, avoid over explaining to avoid noise. +Performance is a constraint, not a feature. Prefer mutation over +allocation on hot paths. Avoid spreading objects, chaining array +methods, or allocating closures inside loops. ### Testing -- **No overlapping tests.** Each test covers one distinct behavior. Do not write a test that - is already an implicit consequence of another test passing. -- **Shared setup goes at the top of the file.** If multiple tests need the same setup - or helper, define it once at the top — not inside each `it()`. -- **Reusable multi-file fixtures go in `tests/fixtures/`.** Anything shared across test files - lives there, not inlined or duplicated. +Unit tests cover behavior and logic only — test what the code does, not +how it's structured. + +No overlapping tests — each test covers one distinct behavior. Do not +write a test that is already an implicit consequence of another test +passing. Shared setup goes at the top of the file: if multiple tests +need the same config or helper, define it once at the top, not inside +each `it()`. Reusable multi-file fixtures go in `tests/fixtures/` — +anything shared across test files lives there, not inlined or +duplicated. ## Versioning -Changelog entries describe the **feature or decision**, not the code change. -Add a code snippet when the API surface changed or the usage is non-obvious. -Include context for non-obvious decisions — why the change was made, not just -what it does. +Every PR with a user-visible change to a public package needs a +changeset in `.changeset/`, describing the change from a consumer's +perspective — the feature or decision, not the code change. Add a code +snippet when the API surface changed or the usage is non-obvious, and +include context for non-obvious decisions — why the change was made, +not just what it does. | Bump | When | | --------- | --------------------------------------------- | @@ -79,19 +149,25 @@ what it does. | **Minor** | New backward-compatible feature | | **Patch** | Bug fix, dependency update, cleanup, refactor | -## Per-package guidance +## Agents -If a package needs rules of its own (build quirks, platform-only -constraints), add an `AGENTS.md` next to its `package.json`. The same -convention applies further down the tree — anywhere a directory has -rules its parent doesn't capture. +If a package needs rules of its own (different stack, platform +constraints), add an `AGENTS.md` next to its `package.json`. Resolution +is nearest-wins: an `AGENTS.md` inside the directory you're editing — +or any ancestor up to the repo root — overrides everything above it for +that directory's code. Read every `AGENTS.md` between the repo root and +the file you're editing, apply them top-down, and let the closest one +settle conflicts. -Resolution is nearest-wins: an `AGENTS.md` inside the directory you -are editing (or any ancestor up to the repo root) overrides everything -above it for that directory's code. When editing a file, read every -`AGENTS.md` between the repo root and that file, apply them top-down, -and let the closest one settle conflicts. +Keep nested files small — encode only what genuinely differs from this +file. A nested file that would just restate the root should be deleted. -Keep nested files small. Only encode what genuinely differs from the -ancestor — duplicating rules invites drift. If a nested file would -just restate the root, delete it. +## Diagrams in docs + +Draw ASCII diagrams (boxes, trees, flows) with plain `|`, `-`, and `+` +only — never Unicode box-drawing characters (`┌ ┐ └ ┘ ─ │ ├ ┼ ▼` …). +Use `|` for verticals, `-` for horizontals, `+` for every corner and +junction, and a plain `v` / `^` / `>` / `<` for arrowheads. The +box-drawing glyphs render inconsistently across fonts, terminals, and +GitHub, and are awkward to edit; the ASCII set is portable and diffs +cleanly. This applies to every `.md` in the repo. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 87a4173..bf4e813 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,13 +1,149 @@ # Architecture - +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 +| +- ... +| ++- / any future host, same shape + +- dialog/ @dunky.dev/-toggle + +- tooltip/ @dunky.dev/-toggle + +- ... +``` + +So a primitive is one package in `core` plus one in each substrate: +`@dunky.dev/` and `@dunky.dev/-`. + +Each substrate directory is itself a private workspace package +(`@dunky-dev/`) 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/ | +| @dunky.dev/ | +| | +| the behavior: states, transitions, guards, callback | +| ordering — no DOM, no framework, no host assumptions | ++-----------------------------------------------------------+ + | + v ++-----------------------------------------------------------+ +| substrate binding packages// | +| @dunky.dev/- | +| | +| 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// + 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/// + 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()` 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, accessibility, 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 ` 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. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d268c43..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,97 +0,0 @@ -# Agent + contributor guide - -The working contract for anyone (human or agent) modifying code in this -repo. This file is the canonical entry point — read it first, every time. - -## Preflight - -Before touching any code, read these in order: - -1. `AGENTS.md` (this file) — rules, boundaries, workflow. -2. `README.md` — what this project is and how to run it. -3. `ARCHITECTURE.md` — the structure and where things live. - -If a per-package `AGENTS.md` exists alongside a `package.json`, read it -before editing files in that package — it overrides anything here for -that scope. - -## Boundaries - - - -## Workflow - -Before finishing any change: - -- **Check affected packages.** A change in one package may need follow-up in - others. Always check what else the change touches. -- **Follow the architecture.** Read `ARCHITECTURE.md` before making structural - decisions. When something doesn't fit cleanly, flag it and suggest a better - approach rather than forcing it. -- **Suggest improvements proactively.** If you notice a cleaner design, a missing - test, or a pattern inconsistency while working, say so — don't just do the minimum. -- **Use available skills.** Before reviewing code use the `/code-review` skill. - Check what's available. - -## Diagrams in docs - -Draw ASCII diagrams (boxes, trees, flows) with plain `|`, `-`, and `+` -only — never Unicode box-drawing characters (`┌ ┐ └ ┘ ─ │ ├ ┼ ▼` …). -Use `|` for verticals, `-` for horizontals, `+` for every corner and -junction, and a plain `v` / `^` / `>` / `<` for arrowheads. The -box-drawing glyphs render inconsistently across fonts, terminals, and -GitHub, and are awkward to edit; the ASCII set is portable and diffs -cleanly. This applies to every `.md` in the repo. - -## Code - -### Performance - -- **Performance is a constraint, not a feature.** Prefer mutation over allocation on hot paths. - Avoid spreading objects, chaining array methods, or allocating closures inside loops. -- **Descriptive names everywhere.** Short names are fine for - local variables with a tight, obvious scope. -- **Comments.** If the code says what it does, the comment is - noise. Write comments to explain _why_: a hidden constraint, a subtle invariant, a - workaround, a hard decision. Keep them short, avoid over explaining to avoid noise. - -### Testing - -- **No overlapping tests.** Each test covers one distinct behavior. Do not write a test that - is already an implicit consequence of another test passing. -- **Shared setup goes at the top of the file.** If multiple tests need the same setup - or helper, define it once at the top — not inside each `it()`. -- **Reusable multi-file fixtures go in `tests/fixtures/`.** Anything shared across test files - lives there, not inlined or duplicated. - -## Versioning - -Changelog entries describe the **feature or decision**, not the code change. -Add a code snippet when the API surface changed or the usage is non-obvious. -Include context for non-obvious decisions — why the change was made, not just -what it does. - -| Bump | When | -| --------- | --------------------------------------------- | -| **Major** | Breaking changes; stable milestone (1.0.0) | -| **Minor** | New backward-compatible feature | -| **Patch** | Bug fix, dependency update, cleanup, refactor | - -## Per-package guidance - -If a package needs rules of its own (build quirks, platform-only -constraints), add an `AGENTS.md` next to its `package.json`. The same -convention applies further down the tree — anywhere a directory has -rules its parent doesn't capture. - -Resolution is nearest-wins: an `AGENTS.md` inside the directory you -are editing (or any ancestor up to the repo root) overrides everything -above it for that directory's code. When editing a file, read every -`AGENTS.md` between the repo root and that file, apply them top-down, -and let the closest one settle conflicts. - -Keep nested files small. Only encode what genuinely differs from the -ancestor — duplicating rules invites drift. If a nested file would -just restate the root, delete it. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c648608..b6263d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,14 +15,15 @@ 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 scaffold ` | Stamps a new primitive across every substrate | +| `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: diff --git a/knip.config.ts b/knip.config.ts index e329dc7..8288595 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -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