Authoritative guidance for AI coding agents (Claude Code, Copilot, Cursor, Codex, etc.) working in this repository. This file is the single source of truth; CLAUDE.md and .github/copilot-instructions.md are symlinks to it.
If a rule here conflicts with code you find in the repo, the rule wins — flag the discrepancy in your response rather than silently following the code.
Marjoram is a pure-functional, signal-based widget SDK for building embeddable interactive components. Ships as ESM/CJS/UMD, ~5KB gzipped, zero runtime dependencies.
createWidget, html, useViewModel, when, signal, computed, effect, batch, untracked, and the types Signal, ReadonlySignal. See src/index.ts.<script>).Run these from the repo root. They are the contract the CI enforces — if any fails locally, do not open a PR.
| Goal | Command |
|---|---|
| Install | npm install |
| Type-check | npm run type-check |
| Lint | npm run lint (use npm run lint:fix to auto-fix) |
| Format | npm run format (check-only: npm run format:check) |
| Unit tests | npm test |
| Tests + coverage | npm run test:coverage |
| Single test file | npx jest path/to/file.test.ts |
| Build | npm run build |
| Dev server (rollup watch) | npm run dev |
Before declaring any task done, run the trio: npm run type-check && npm run lint && npm test. CI runs on Node 16/18/20 — keep code compatible with all three.
src/
├── index.ts # Public exports — treat as the API surface
├── widget/ # createWidget — high-level widget factory + lifecycle
├── view/ # html tagged template, view types, internal vs external utils
├── useViewModel/ # useViewModel — standalone reactive view models
├── schema/ # Schema + reactive props (SchemaProp, useSchema, factory)
└── reactivity/ # Low-level signal/computed/effect/batch primitives
__tests__/
├── view/ # html template and rendering tests
├── viewModel/ # useViewModel tests
├── reactivity/ # signal/computed/effect tests
├── edge-cases/ # Bugs caught in the wild — read before changing core paths
└── benchmarks/ # Performance regression suite (see §8)
docs/ # Long-form docs (TYPED_SCHEMA_PROPS, PERFORMANCE_TESTING)
demo/ # Working examples consumers may copy
dist/ # Build output — never edit by hand
When adding a new public symbol, export it from src/index.ts and add a test under the matching __tests__/ subdirectory.
These are the conventions consumers depend on. Violating them silently breaks user code.
$ prefix rulevm.$prop → returns the reactive SchemaProp. Use inside html templates for reactive binding.vm.prop → returns the plain value. Use in event handlers, effects, and imperative logic.// CORRECT — reactive binding
html`<p>${vm.$name}</p>`;
// WRONG — renders once, never updates
html`<p>${vm.name}</p>`;
// CORRECT — value access in logic
vm.name = "Updated";
.compute() — single-prop transforms inside templateshtml`<span>${vm.$active.compute(v => (v ? "Active" : "Inactive"))}</span>`;
html`<ul>${vm.$items.compute(items => items.map(i => html`<li>${i}</li>`))}</ul>`;
when() — conditional renderingimport { when } from "marjoram";
html`<div>${when(vm.$isLoggedIn, () => html`<p>Welcome</p>`, () => html`<p>Log in</p>`)}</div>`;
ref + collect() — element referencesconst view = html`<button ref="myBtn">Click</button>`;
const { myBtn } = view.collect();
myBtn.addEventListener("click", handler);
createWidget returns a Widget with .mount() and .destroy(). Always call .destroy() when removing a widget — it tears down DOM, observers, and effects. onMount(vm, refs) and onDestroy(vm) hooks are the consumer extension points.
In a model, functions become computed props derived from other reactive state. They cannot be assigned to — update the source.
const vm = useViewModel({
count: 0,
doubled: vm => vm.count * 2,
});
vm.count = 5; // ✅
vm.doubled = 10; // ❌ runtime/type error
| ❌ Don’t | ✅ Do | Why |
|---|---|---|
html\${vm.name}` | html`${vm.$name}\ `` |
Static binding; will not update | |
vm.doubled = 10 |
Update the source signal | Computed props are read-only |
document.body.appendChild(view) |
view.mount('#target') or createWidget |
Skips lifecycle; leaks effects |
Skipping widget.destroy() |
Always destroy on teardown | Memory + observer leaks |
| Adding a runtime dependency | Inline the helper | Library is zero-dep by contract |
Introducing class syntax |
Use functions + closures | Library is class-free by contract |
any in public types |
Generics or explicit unions | ESLint warns; reviewers reject |
innerHTML = userInput |
Use html\`` — auto-escapes |
XSS safety is non-negotiable |
| Absolute-timing perf assertions | Ratio vs. baseline | See PERFORMANCE_TESTING_PHILOSOPHY.md |
es5, 80-col, arrowParens: "avoid". Don’t argue with it — run npm run format.prefer-const, no-var, eqeqeq (always ===), no-console (warn — strip before merging), @typescript-eslint/no-explicit-any (warn), @typescript-eslint/no-inferrable-types (error).camelCase for values, PascalCase for types/interfaces, kebab-case for files only if multi-word and not a default export. Match the existing file’s convention before inventing a new one.These are load-bearing — changes that violate them require a design discussion in an issue first.
devDependencies are fine; dependencies must stay empty. Inline what you need.reactivity/ primitives. No virtual DOM, no diffing, no proxies leaking out.html tagged template auto-escapes interpolations. Never bypass it. Never expose a “raw HTML” escape hatch without a security review.shadow: 'open' | 'closed'). styles are scoped to the shadow root.dist/ size after non-trivial changes; flag growth >5% in the PR description."sideEffects": false in package.json enables aggressive tree-shaking — don’t add top-level side effects.@testing-library/dom. Setup in setupTests.js.src/ under __tests__/. A change in src/view/view.ts adds/updates tests in __tests__/view/.mount → DOM present, destroy → DOM cleared + no dangling effects).__tests__/edge-cases/.test.skip based on environment. Use ratio-vs-baseline assertions with adaptive thresholds (lenient in CI, strict locally). Always include a warmup iteration and use median, not mean.npm run test:coverage and inspect coverage/lcov-report/index.html.feat:, fix:, docs:, style:, refactor:, test:, chore:, perf:. Breaking changes get a ! (e.g. feat!: rename X) and a BREAKING CHANGE: footer.dist/ byte diff).dist/ (CI builds it), .env, coverage/, IDE files. .gitignore should already cover these — if not, fix it in a separate chore: PR.--no-verify to bypass hooks. Fix the failing check.view/ or widget/)html\` (no innerHTML, outerHTML, insertAdjacentHTML, eval, new Function, document.write`).onclick=${userInput} where userInput is a string).document-wide selectors from inside a widget.Function/eval reintroduced.When an AI agent is driving a change end-to-end:
type-check, lint, and test before reporting done. “I think it works” is not done.__tests__/, demo/, example-usage.ts, README.md) and update them in the same PR.