This repository is Bun + Effect-4 + Foldkit only; the Openstory fork line stays behind
The core line drops the React shell, CSF-3, and the pnpm/turbo/vite toolchain. They remain on the foldkit branch as history. The accepted cost is that a Bun-only artifact narrows the audience, since the Foldkit community runs Node + Vite + npm/pnpm.
Status: accepted · created 2026-07-31 · updated 2026-07-31
0002 — Bun + Effect-4 + Foldkit only; the fork line stays behind
Context
This repository started as tao-io's fork of millionco/openstory (MIT): a
framework-agnostic component explorer built on pnpm workspaces, turbo, Vite, a prebuilt
React shell, and CSF-3 story files, with adapters for React, Solid, Vue, Svelte, and
Foldkit. That line is on the foldkit branch (over main, which tracks upstream).
Meanwhile the part that carries the value — the headless runner, the MCP catalog server,
the Schema-table generator, the coverage collector — was written separately as a Bun +
Effect-4 CLI: about 1300 lines of source and 1100 of tests, 40 passing bun tests, no
DOM, no Vite, no React. It became clear that the CLI is Foldcase, and the shell is
scaffolding around it.
Carrying both forward means carrying two package managers, two test runners, two build
systems, and two definitions of a component (CSF-3 metadata and the Showcase record of
ADR-0001). That is a permanent tax on a small project.
Decision
The core line is Bun + Effect-4 + Foldkit. Nothing else is carried forward.
In scope:
Bun — the runtime, the package manager, the test runner (
bun test), and the bundler (bun build --compileproduces the singlefoldcasebinary).Effect v4 — all non-trivial logic. Typed errors, Layers,
Schemafor every contract. Effect is a peer dependency, so a consumer and Foldcase share one Effect instance.Foldkit — the only framework this line supports. Foldcase declares no dependency on it (
playis opaque, per ADR-0001), but every design choice assumes Foldkit's serializable Model, typed Message union, and pureupdate.TDD — no production code without a failing test first, one vertical slice at a time. Tests are co-located and exercise public interfaces.
Explicitly not carried forward, and staying on the foldkit branch:
the prebuilt React shell and the
/__openstory/*runtime plumbing;CSF-3 (
Meta/StoryObj,export default meta) and the story-file parser;the React, Solid, Vue, and Svelte adapters (
foldcase/reactand friends);pnpm workspaces, turbo, and Vite, including
pnpm-workspace.yaml,turbo.json, and the rootvite.config.ts;Vitest, Playwright, and the visual-snapshot gate built on them.
The component-file suffix is *.showcase.ts, not *.stories.ts. The catalog is a plain
export const showcases: ReadonlyArray<Showcase> — no default export, no parse step.
The one declared exception
foldcase test --coverage spawns Node. Bun exposes no programmatic V8 precise
coverage — node:inspector's Coverage domain is unsupported, NODE_V8_COVERAGE is
ignored, and bun:jsc.codeCoverageForFile is broken — so per-Showcase attribution runs
under Node's inspector Profiler via a small .mjs instrument that is spawned, not
bundled. Consequences, stated plainly rather than hidden:
--coverageneeds Node on PATH and runs from source, so it does not work from the compiled binary.src/coverage/collector.mjsis the only sanctioned non-TypeScript, non-Effect source file in the repo. It is named in the lint ignore list and in the stack gate's allowlist, so it is an exception by declaration, not by accident.Coverage is additive: it never changes the run's pass/fail exit code, and a collection failure degrades to a warning.
Considered options
Keep both toolchains in one repo (a Bun package next to the pnpm workspace) — rejected: two package managers and two test runners in one tree, and the CSF-3 catalog would remain a second definition of a component, breaking ADR-0001.
Port the shell to Bun and keep the React UI — rejected: the React shell is the largest piece of the fork and the least aligned. If a browser lab is wanted, it should be a Foldkit app, which is already the fork's own roadmap item.
Leave the CLI where it was written, inside its host repo — rejected: it has three commands, a public API, and an outside audience; it needs its own release line, its own issues, and its own README.
Ship a Node-compatible build alongside the Bun one — not rejected, deferred. See the open questions.
Consequences
The audience narrows, and this is the real cost. The Foldkit community runs Node + Vite + npm/pnpm. A Bun-only artifact —
binpointing at a.tsentry,exportspointing at.tssource,bun:testin the suite — is installable by Bun users and awkward for everyone else. Accepted for now, because the alternative is a transpile-and-publish pipeline before the code has any users. It is the first thing to revisit if adoption stalls.The published
foldcasepackage changes shape. Anyone importingfoldcase/foldkit,foldcase/react, or runningfoldcase devis on the fork line (0.1.x) and must stay there or migrate. This is a breaking change for existing consumers of the tarball, and the version numbering must say so.Effect v4 is beta and moves. The peer range is
>=4.0.0-beta.90rather than an exact pin, which is what the fork learned when exact-beta pins caused resolution conflicts for consumers. Breaking beta renames land on us, not on the consumer.Two lint gates instead of three. The CosmOS Effect-4 idiom rules live in private, unpublished oxlint plugins that a standalone repo cannot resolve, so the syntactic half of the purity gate is missing here. See the open questions.
--coverageis the seam where "Bun only" is not literally true. It is scoped, named, and gated; it is not a precedent.
Enforcement
test/stack.test.ts— a zero-dependencybun test(bun:test+node:fsonly, importing no project code) that fails on:A rival lockfile —
package-lock.json,yarn.lock, orpnpm-lock.yaml.A rival toolchain file —
pnpm-workspace.yaml,turbo.json,vite.config.*, orvitest.config.*at any level.A banned dependency in
package.json—react,react-dom,solid-js,vue,svelte,vite,vitest,turbo,storybook, or any@storybook/*.A foreign source file under
src/— any.jsx,.tsx,.vue,.svelte, or any.js/.mjs/.cjsfile other than the single allowlistedsrc/coverage/collector.mjs.A CSF-3 catalog — any
*.stories.*file, or a*.showcase.tsfile whose catalog is a default export instead ofexport const showcases.A
.envfile anywhere, at any level. The allowlist in that test is one line; extending the fence is a deliberate edit to it.
.oxlintrc.json›eslint/no-restricted-imports— the syntactic half: importingreact,react-dom,solid-js,vue,svelte,vite, orvitestis an error at G1 speed, before the test suite runs.mise run typecheck— the patchedtscloads@effect/language-service, so Effect-4 semantic diagnostics (missing error channels, floating Effects,SchemaoverJSON.parse) ride along with the type check..github/workflows/ci.yml— runslint,typecheckandteston every push and pull request, then buildsdist/and smoke-runs the built CLI under both runtimes (mise run smoke), under the Bun and Node versionsmise.tomlpins. There is no second CI path that could pass with a different toolchain.Judgment, not gated (by design): "prefer Effect for non-trivial logic" and "one vertical slice at a time" are review and convention. A gate can prove React is absent; it cannot prove the code is idiomatic.
Open questions
The vendored oxlint plugin gap. CosmOS enforces 59 Effect-4 idiom rules through two oxlint
jsPlugins— a fork of@mpsuesser/oxlint-plugin-effectand an in-house ADR plugin. Both are"private": truepackages resolved over afile:path inside that repo, so this repository cannot use them. Three routes exist and none is chosen yet: depend on the public upstream plugin and lose the fork's fixes; publish the fork under a public scope; or vendor a builtdist/here and carry a freshness guard. Until one is picked,avoid-process-env,use-console-service,prefer-effect-fn,avoid-untagged-errorsand the rest are unenforced here, and only@effect/language-servicecovers the semantic half.Node-compatible distribution. Should Foldcase publish a transpiled, Node-runnable build (
dist/*.jsplus a Node shebang) next to the Bun-native source, so the Foldkit community can install it with npm or pnpm? That would need a bundler this ADR currently bans, a second entry inexports, and a decision about whetherbun:testusage in the runtime path (there is none today) stays absent. Unresolved; it is the trade-off named above and the most likely reason to amend this ADR.The browser lab. If a lab shell is built as a Foldkit app in this repo, it needs a dev server. Foldkit's own tooling is Vite-based, which collides with the toolchain fence above. Whether the lab lives here, in a sibling package, or is dropped is undecided.
Amendment 1 — 2026-07-31: a runtime-agnostic core, thin per-runtime shells
Open question 2 above — "Should Foldcase publish a transpiled, Node-runnable build next to the Bun-native source?" — is now answered yes, and it is the amendment this ADR predicted would come first. The record below stands as written; this section says what changed and why. The decision has three parts.
1. The runtime fence moves
The original fence was "Bun only". It is now a runtime-agnostic core plus thin per-runtime shells:
The core is runtime-agnostic Effect.
src/runner.ts,src/cli.ts,src/program.ts,src/docs/*,src/mcp/*andsrc/coverage/*name no runtime. The platform services they need —FileSystem,Path,ChildProcessSpawner,Stdio— arrive from the Effect context, andsrc/program.tsreturns the exit code rather than writing toprocess.A shell binds exactly one runtime.
src/main.tsis the Node shell (@effect/platform-node,NodeRuntime,NodeServices.layer);src/main.bun.tsis the Bun shell (@effect/platform-bun,BunRuntime,BunServices.layer). Each is about fifteen lines: bind a runtime, hand the programprocess.argv, write back the exit code. Logic that appears in a shell is a bug.The one thing a shell may know is its own runtime's quirks. Node strips TypeScript types but does not rewrite relative specifiers, so a showcase importing
./Buttonor./Button.jsdoes not resolve there; the Node shell installs aregisterHooksresolver for it. Bun resolves both itself and its shell installs nothing. The policy (which candidates to try) lives insrc/shell/nodeResolution.tsand is unit-tested; only thenode:modulecall sits in the shell.
This also settles the third contradiction the port found: @effect/platform-bun was
declared an optional peer while src/main.ts and src/mcp/server.ts imported it
unconditionally, so the bin hard-required an optional dependency. Now the MCP server
Layer takes FileSystem | Path | Stdio from the context like everything else, both
platform packages are optional peers, and each is optional truthfully: a consumer needs
only the one their shell runs, and the library entry points (foldcase, foldcase/cli,
foldcase/mcp) need neither.
2. The published artifact is a tsc-built dist/, not .ts source
exports and bin used to point at src/*.ts, which only Bun can consume. They now point
at dist/*.js with a .d.ts beside each, in the same { "types", "import" } shape the
Foldkit package itself publishes — which is what makes it installable from npm and usable
from Node and Vite as well as Bun. files ships dist, engines names Node, and the
version restarts at 0.1.0: nothing was ever published under the core line, so the
clean number wins.
The build is plain tsc -b tsconfig.build.json. No bundler. This is the part of the
original fence that tightens rather than relaxes:
tscis a compiler, not a bundler. It emits one.js+ one.d.tsper source file and rewrites nothing else, so the published tree is the source tree and a consumer's own bundler (Vite, or none) sees ordinary ESM.bun build --compileis dropped as a release artifact, and not only on principle. A compiled Bun binary resolvesimport(path)inside its embedded/$bunfs, so./foldcase test <dir>could never load an external*.showcase.tsfile: the artifact was broken by construction for the tool's primary command. Loading arbitrary user TypeScript at runtime is what Foldcase is, so a single-file binary is the wrong shape for it.No bundler is a dependency, and none is invoked. Vite, Vitest, turbo and the rest stay banned exactly as before;
tscjoins Bun in the toolchain rather than replacing the ban.
src/coverage/collector.mjs is copied into dist/ by the build, because tsc moves
TypeScript and this file is deliberately not TypeScript. It remains the one declared
non-TypeScript source (see The one declared exception).
3. What does not change
No React, Solid, Vue or Svelte — in source, in tests, or in
devDependencies.No CSF-3. A catalog is still
export const showcasesin a*.showcase.tsfile.No Vite and no Vitest as our dependencies. Foldcase is now consumable from a Vite project; it does not become a Vite project.
Effect stays a peer dependency, so a consumer and Foldcase share one Effect instance.
Bun stays the development toolchain:
bun testis the suite,bun installthe package manager,misethe version pin.TDD stays mandatory, one vertical slice at a time.
Consequences
The audience cost named in Consequences above is paid off. A Foldkit user on Node + npm/pnpm + Vite can install and run Foldcase. That was the accepted cost of the original decision and the stated trigger for revisiting it.
Two shells is two code paths to keep honest, so the fence is gated rather than reviewed (below). The shells are small enough that "keep them thin" is checkable by eye, and the gate checks the part that is not.
foldcase testunder Node needs a Node that strips types — Node 22.18 or newer, whichenginesstates. Under Bun any supported Bun works. Nothing else in the package needs more than Node 18.--coveragestill spawns Node, and now for a second reason: it is the only runtime with programmatic V8 precise coverage. Under the Node shell the spawn is the same runtime the CLI is already running on, which makes the exception less strange, not more.
Enforcement (added by this amendment)
test/stack.test.ts gains three clauses, each proven to fire on a planted violation:
The runtime fence — only the two declared shells import an
@effect/platform-*package; each imports exactly the one it is named for; and every package a shell binds is declared inpeerDependenciesand marked optional inpeerDependenciesMeta.No bundler — no bundler is declared as a dependency, and no build task invokes one.
mise.toml'sbuildtask must runtsc -b.The published shape — every
exportssubpath is a{ "types", "import" }pair pointing intodist/,binpoints intodist/,filesshipsdistand notsrc, andenginesnames Node.
Two shells and a compiled dist/ are things a bun test run cannot see, so CI grew a
second job: mise run build followed by mise run smoke, which drives dist/main.js
under Node and dist/main.bun.js under Bun through the usage banner, test, --coverage
and docs. It proves both bins exist and are executable, that each runtime resolves a
consumer's extensionless TypeScript import, that the coverage collector is really copied
into dist/coverage/ and spawns, and that the exit codes are what the README promises.
mise.toml also pins Node 22.18.0 — the floor engines claims and the first release
with unflagged type stripping — so nothing is tested against whatever Node a machine
happened to have.
test/surface-derivation.test.ts › Exports drift keeps its job across the change: an
entry point is now checked against the source it is built from, and additionally
against the build output whenever dist/ is present. A gate that only passed after a build
would be a trap — green on a developer's machine, red on a clean checkout — so it is the
mapping from dist/x.js back to src/x.ts that is always enforced, and the built file on
top of it when there is one.
Amendment 2 — 2026-08-04: the repository stands on its own, and core is main
The record above describes a fork: a core branch for this line, main tracking the
Openstory upstream, and a GitHub repository marked forked from millionco/openstory. That
arrangement cost more than it explained. A fork cannot open Issues, so bugs.url pointed
at a page nobody could post to; the front page showed the upstream README to anyone who
arrived; and the branch named main held a codebase this line does not build, test or
publish.
So the repository is now tao-io/foldcase, created fresh rather than forked, and this line
is on main. The fork line — every branch of it, including the six feature branches that
were open — is pushed alongside as history, and main does not descend from it.
The attribution does not change. Openstory is where the play contract and the
SerializedError shape come from, LICENSE keeps its copyright notice, NOTICE records
what is derived and from whom, and the README says so in prose. Detaching the git
relationship removes a claim about branch ancestry, not a claim about credit.
Amendment 3 — 2026-08-04: Vite comes back, for the documentation site only
The list above puts Vite among the things that stay on the foldkit branch, and the
stack gate enforced that absolutely. ADR-0003
opens one hole in it: the Foldcase documentation site lives in this repository under
docs/site/, it is a foldocs application, and foldocs is built by Vite.
The reasoning is ADR-0001's, not a toolchain preference: the site's content is this
repository's README.md, CHANGELOG.md and docs/adr/*, so a second repository would
either copy that prose or derive it across a pin that can lag. Keeping the site here
removes the drift window entirely — its pages are generated from the working tree on every
build and are not committed.
The exception is declared, not inherited: one directory, one vite.config.ts, one manifest
that may name vite, and two mise tasks, each pinned by name in test/stack.test.ts. The
same change made the fence stricter elsewhere — every manifest in the repository is now
read for banned packages and bundlers, where before only the root one was. Nothing about
the tool changes: mise run build is still tsc -b, the tarball still withholds
docs/, and no bundler touches src/.
Last updated Aug 4, 2026