Guidance for AI coding agents (Cursor, Copilot, Claude Code, …) working in
this repository. For Claude Code the same rules live in
CLAUDE.md; the detailed developer guidelines are in
.claude/ — read the one that matches your task:
.claude/architecture.md— repo structure and design.claude/typescript.md— TypeScript style, principles, naming.claude/bun.md— Bun runtime, APIs, testing, frontend.claude/testing.md— test setup, style, examples.claude/comments.md— comment and JSDoc guidelines
Consuming a published package rather than editing the repo? Each package ships an
AI.mdnext to itsREADME.mdwith a mental model, a minimal example, and the gotchas most likely to trip up an LLM. Start there.
Adding an export means adding a line to that package's AI.md.
bun run check:ai-docs reads what each package publishes and fails on anything
its AI.md never mentions — a new subpath, a new function, a native package
with no Status section. It is not a style check: these files are the only
documentation a coding agent consuming the package will ever read, and a wrong
one is worse than a missing one because nothing about it looks stale. An export
no consumer ever writes goes in INTERNAL_EXPORTS in scripts/ai-docs.ts, with
the reason.
mini is a Bun monorepo holding a deliberately tiny signals UI runtime in
two shapes:
packages/mini—@amritk/mini, reactive DOM bindings plus a compilerless JSX runtime.packages/mini-lynx—@amritk/mini-lynx, the same model rendered through a pluggableHost, so it targets a native view tree, the DOM, or plain objects.
Two more sit alongside them, for the part of a Lynx app that is not rendering:
packages/mini-lynx-native —
@amritk/mini-lynx-native, the wire between Lynx's main-thread and background
contexts, because NativeModules lives only in the latter and the runtime lives
only in the former — and five native modules built on it, with Android and iOS
sources of their own:
packages/lynx-notifications —
@amritk/lynx-notifications, local and remote push —
packages/lynx-location —
@amritk/lynx-location, device location —
packages/lynx-dialogs — @amritk/lynx-dialogs,
the platform's own date picker, action sheet and alert —
packages/lynx-deep-linking —
@amritk/lynx-deep-linking, deep links in and out — and
packages/lynx-secure-storage —
@amritk/lynx-secure-storage, the Keychain and EncryptedSharedPreferences
behind a string-to-string store, because Lynx ships no storage at all and a
credential has to live somewhere. The Kotlin compiles in CI —
bun run check:android — and a parity suite pins their method surfaces against
the TypeScript. The Objective-C compiles nowhere automatic: pod lib lint on a
macOS runner cost 81 minutes a run and is commented out in
.github/workflows/ci.yml, so run it by hand on a Mac when you touch ios/.
None of it has run on a device. See each package's AGENTS.md for what that
does and does not cover.
The five are deliberately alike: each was built from the last one's shape, so a
structural change to one is usually owed to the others. Where one diverges it
says why — lynx-dialogs has no events, because a dialog is asked once and
answers once, so it carries none of the GlobalEventEmitter fan-out the others
need; lynx-deep-linking is the only one whose inbound half starts outside
any LynxView, which is why it owns a ContentProvider and a +load and why its
launch URL is a value rather than an event; lynx-secure-storage is the only
one that throws on part of its surface, because null from a read has to
keep meaning "there is no such key" and never "the store would not open".
Alongside them, packages/mini-helpers —
@amritk/mini-helpers, the handful of helpers that turned out to be identical
in both (route matching, query parsing, JSON Schema compilation). It is a leaf:
it imports neither package, and its charter is no reactivity, no platform,
enforced by src/purity.test.ts. Neither package's public surface changed —
both re-export it from the subpath it already lived on.
Two more are about getting started rather than about running an app.
packages/mini-lynx-preview —
@amritk/mini-lynx-preview, Lynx's Element PAPI implemented over the DOM, so an
app runs in a browser tab unmodified. It was apps/playground-mini-lynx/src/lib/
until something outside this repo needed it: a scaffolded app cannot import a
private app's src/. It imports the runtime for types only, so the wiring —
setEngine, mount — stays in the app and a preview can never hand an engine to
a second copy of the runtime. And
packages/create-mini-lynx —
@amritk/create-mini-lynx, bun create @amritk/mini-lynx my-app: a template
copied to a directory, then the user's package manager, ending at a running app.
The app it writes carries both loops over one src/app.tsx — bun run dev
is the preview above, bun run dev:device is the rspeedy build below — because
which one you reach for is a question about the change you are making, not about
the project you started.
Each is independently published and carries its own AGENTS.md with the
invariants that package cannot break.
One package is not a runtime at all:
packages/mini-lynx-rsbuild-plugin —
@amritk/mini-lynx-rsbuild-plugin, the rspeedy build for a mini-lynx app. It
runs on a laptop rather than on a phone, and it exists because a Lynx template
is a container with two code slots rather than a bundle with an entry point:
which code goes in which slot is the framework's to say, ReactLynx says it in
@lynx-js/react-rsbuild-plugin, and there is no framework-agnostic plugin
underneath to reuse. Its own AGENTS.md carries the invariants; the loop it
serves, and what about that loop has and has not been verified, is
docs/mini-lynx-explorer.md.
Alongside them sit two private kitchen-sink playgrounds — apps/playground-mini
and apps/playground-mini-lynx — that exercise every public entry point and
deploy to Cloudflare Workers as static SPAs. They are the only code here written
the way a consumer writes it, which makes them the fastest way to see a change
and the place composition-level defects surface first.
The playground previews through @amritk/mini-lynx-preview, the same package a
scaffolded app boots on — so the engine under the kitchen sink and the engine
under a five-minute-old starter are one implementation, and a defect in it
surfaces in the app with eighteen screens rather than in the one with a counter.
A third app, apps/starter-mini-lynx, is a starter rather than a
playground: four files, built by rspeedy into a real .lynx.bundle and opened
on a device through Lynx Explorer. It is the build plugin's playground in the
sense the rule below means — the only place that package is used the way a
consumer uses it — and the only app here that does not run in a browser.
apps/playground-mini-lynx covers the bridge and all five native modules too,
which a browser has no more of than it has an engine. Its src/lib/fake-device.ts
is the answer: it installs both halves of the bridge over the fake contexts the
package publishes, and registers each module's own published fake as the
registry — so the screens drive the shipping facades against the same contract
those packages' suites assert on, and nothing about a module is reimplemented
for the preview.
A new package, or a new entry point on one, is not finished until it has a
playground screen. Register its published fake in src/lib/fake-device.ts,
add the screen under src/screens/, put it in the route table and add it to
src/screens.test.ts. This is not a documentation chore: the playground is the
only place in the repository where a package is used the way a consumer uses it,
and every composition-level defect this repo has found was found there rather
than in a suite. A package with no screen is a package nobody has actually
tried.
The rule is about being used like a consumer uses it, not about the screen.
@amritk/mini-lynx-rsbuild-plugin has no runtime surface to render, so its
equivalent is apps/starter-mini-lynx: a real app that builds through it, plus
a test that runs one real rspeedy build and drives the artifact. A build-time
package is finished when a change to it can break something a consumer would
notice, in this repo, without a device.
Two more packages have no surface to demo either, and each has its own
equivalent. @amritk/mini-lynx-preview is what the playground itself boots on,
which is a stronger statement than a screen would be — every screen is a test of
it. @amritk/create-mini-lynx produces a directory of files rather than an API;
its guard is template.test.ts, and the app it writes is the two loops this
repo already runs — the preview the playground boots on, and the rspeedy build
apps/starter-mini-lynx uses. None of the three is a precedent for a package
that does have a runtime surface.
bun install # install workspace deps
bun run test # run every package's tests (packages/* only)
bun run check # biome lint + format check
bun run check:reactivity # guard the compilerless-JSX called-signal footgun (packages + apps)
bun run check:ai-docs # every package's AI.md against what that package actually exports
bun run check:android # compile the notifications Kotlin (needs ANDROID_HOME; skips without)
bun run types:check # type-check every package and every app
bun run build # build every package and every app (the starter builds a .lynx.bundle)
bun run test:dist # load and drive the built dist/ artifacts (needs a prior build)
bun run bench -- --baseline <dir> # bundle-size delta against another checkoutPer package: bun run --filter='@amritk/<name>' test (and build, types:check).
Per playground: bun run --filter='@amritk/playground-mini' dev (and build,
preview, deploy).
The cap is the design. No virtual DOM, no diffing, no re-render. JSX builds
real host nodes once; dynamic values flow through bind helpers or
function-valued props; repetition goes through list. If a feature seems
missing, the answer is usually "reach for a bigger framework", not a new
helper — and a feature only some apps need belongs on a subpath, because the
. entry is byte-budgeted and src/core-size-budget.test.ts holds the ceiling.
Everything more specific lives in the per-package AGENTS.md. Read it before
editing that package — the scope-ownership and reserved-key gotchas in
particular have both bitten before and both have regression tests you should
not "fix".
"Compilerless" means no transform is ever required for correctness. Both
packages sit at the standard JSX transform plus an optional plugin for
diagnostics (@amritk/mini/vite). Above that they diverge deliberately:
ministops at diagnostics. Its consumer bundles into somebody else's page against a byte budget a test enforces, so a required build step is friction on the one thing the package is for.mini-lynxmay add optional optimisation, because its consumer owns a whole app toolchain — under the invariant that an app skipping the plugin still renders correctly, only slower and larger.
Note what this protects that is easy to miss: the two packages are the same
design re-derived, which is why a defect found in one is worth hunting in the
other. A transform that changes semantics on one side ends that, and the
cross-check is how both shipped gotchas above were found.
docs/mini-lynx-cross-platform.md §18
has the full accounting.
- Add a changeset with every PR. Run
bunx changeset, pick the affected packages and a semver bump, commit the file under.changeset/. For docs/tooling/CI changes that touch no published package, usebunx changeset --empty. - Never put Claude/session links, tracking IDs, or platform attributions in commits or PR text — keep them focused on the code.
- Match the surrounding code's style, comment density, and naming. Biome
(
biome.json) is the formatter and linter; runbun run checkbefore you're done. - Both packages ship their
src/, so source comments reach consumers — keep them accurate. - Shared runtime dependencies go through the root
catalog. Both packages declarealien-signalsas"catalog:"; change the version once, in the rootpackage.json, never in a package manifest.catalog:/workspace:are resolved at publish time byscripts/resolve-workspace-protocol.tsbecause npm understands neither — see.claude/bun.md. - Pre-alpha: breaking changes are allowed but must ride a minor version bump via a changeset.