FoldKit apps drawn natively by GPUI (Zed's GPU UI framework), in-process, through gpuix's Node/Bun bindings. No web view, no browser.
React is to React Native as FoldKit is to FoldKit Native: an
ordinary FoldKit app (its Runtime, h views, Commands, Subscriptions, CSS
and @foldkit/ui components) renders into a native window or a Wayland
layer surface, within the adapter's capability limits.
FoldKit itself is not modified or forked.
Status: installable test packages, under qualification. Runs on Linux (Wayland) and macOS (Apple silicon, Metal). gpuix also ships a Windows binary, untested here.
FoldKit draws by patching a DOM. The primary renderer, foldkit-gpuix,
supplies a small DOM API whose nodes are gpuix host nodes. FoldKit's snabbdom
patches update GPUI's retained tree directly, without happy-dom or a mirror:
FoldKit ──snabbdom──▶ native document ──host──▶ gpuix ─▶ GPUI
FoldKit ◀──DOM events── native document ◀──events, focus, scroll── GPUI
- The document and host (
packages/foldkit-gpuix/src/dom.ts,host.ts) provide the DOM APIs FoldKit uses and translate GPUI's input into DOM events. Inputs are GPUI editors; the document follows native focus. The host handles Tab order, modal focus scopes, pointer events and drag and drop. - Styles are CSS. The adapter's sheet (
packages/foldkit-gpuix/src/sheet.ts) matches supported selectors, resolves custom properties and translates styles into what GPUI can draw. GPUI owns layout; geometry reads use its last available layout.:hover,:active,:focusand:focus-visible, live size media queries and viewport units are supported. Sibling selectors, pseudo-elements and states on ancestors are among the limits reported inapp.unsupported. Modern CSS such as Tailwind 4's is lowered withbun run css(scripts/css.ts). See the adapter guide and migration guide for the supported subset. Like a native app, UI text isn't selectable unless its CSS saysuser-select: text, so double-clicking a button never highlights its label. - Primitives (
src/primitives.ts): look-free building blocks for views (box,stack,row,grid,text,image,input,scroll,surface) with stabledata-partslots, state attributes and focus/motion hooks. No styled components and no theme: those belong to a UI library built on top. See THEMING-SEAMS.md. - Tokens (
src/theme.ts): semantic tokens as CSS custom properties, switchable at runtime without a restart.
Build the tarballs from this repository (Building installable packages),
then install them into your own project, alongside the pinned peers
(<version> is the version in package.json):
npm install /path/to/foldkit-native-<version>.tgz /path/to/foldkit-gpuix-<version>.tgz /path/to/foldkit-native-ui-<version>.tgz effect@4.0.0 foldkit@0.165.0Save this complete counter as app.ts in that project and run bun app.ts.
The packages contain ESM JavaScript and TypeScript declarations; no library
build or repository tsconfig paths are needed. These libraries are installed
with npm; they are not standalone desktop application installers. The
packages have not been published to the npm registry.
import { Schema } from 'effect'
import { Runtime } from 'foldkit'
import type { HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { mountGpuix } from 'foldkit-gpuix'
const Model = Schema.Struct({ count: Schema.Number })
type Model = typeof Model.Type
const Message = defineMessageUnion({ Incremented: {} })
type Message = typeof Message.Type
const init = () => ({ model: { count: 0 } })
const update = (model: Model, message: Message) =>
Message.match(message, {
Incremented: () => ({ model: { count: model.count + 1 } }),
})
const view = (model: Model, h: HtmlBuilder<Message>) =>
h.div([h.Class('app')], [
h.p([], [`Count: ${model.count}`]),
h.button([h.OnClick(Message.Incremented())], ['+1']),
])
const css = '.app { padding: 24px; gap: 12px; display: flex; flex-direction: column; } body, .app { background-color: #ffffff; color: #000000; }'
const app = mountGpuix({ title: 'My app', width: 800, height: 600, css })
app.own(Runtime.embed(Runtime.makeElement({ Model, init, update, view, container: app.container })))own() ties the runtime's disposal handle to the app. app.close() disposes
owned runtimes before releasing the adapter's tree, listeners, frames and
globals. By default closing ends the process. With exitOnClose: false, a
host can await app.close() and app.closed, but gpuix 0.10 cannot release
its native window or event callback from JavaScript. On macOS the red close
button exits inside GPUI without running JavaScript close handlers. See
closing, errors and starting
for those limits. Runtime.run provides no disposal handle.
mountGpuix accepts gpuix's window options, including layerShell for a
Wayland bar, dock or overlay. appId selects a durable localStorage data
folder; without it or dataDir, storage is in memory. Pass tokens and use
app.setTokens(otherTokens) to switch CSS custom properties live.
The setup test typechecks this exact snippet and executes its model, view and owned runtime on the real adapter with a fake renderer. Native startup of this snippet remains pending platform qualification; headless checks do not establish window or pixel behavior.
The older happy-dom → MutationObserver → GPUI mirror (src/mirror.ts) stays
available for comparison and fallback while the adapter is qualified. Its
CSS cascade and browser emulation have different limits. Existing mirror apps
can still use mountNative from foldkit-native; use the direct adapter
above for new setup. To compare a ported example:
FOLDKIT_NATIVE_RENDERER=mirror bun run example weatherExamples default to the adapter. Native UI and Layer Bar require it; see EXAMPLES.md for renderer selection.
Needs Bun and Node's npm (for the exact lockfile). On
Linux, also Wayland, EGL/Vulkan and libxkbcommon on the library path (NixOS:
put them on LD_LIBRARY_PATH, or use nixos-config's nix develop .#render).
Without them gpuix says Cannot find native binding. npm has a bug…, which
means a missing system library, not a missing package. On macOS nothing else: gpuix's Apple silicon
binary draws with Metal. Run it from a terminal in the logged-in desktop
session (Terminal.app, at the machine or over Screen Sharing) so the window
appears on that screen.
npm ci # exact pins; installs only your platform's gpuix binary
bun run gallery # every example in one window; click one to open it
bun run gallery --list # the same list in the terminal
bun run example weather # open one example directly
bun run demo # examples/themes.ts: primitives + two token sets, switched at runtime
bun run counter # examples/counter.ts: a FoldKit counter; only the setup import is native-specific
bun test # everything this machine can run (TESTING.md)
bun run typecheck
bun run css # regenerate the examples' CSS after changing classes or styles.css
bun run record weather # macOS: a still and a clip of an example, from its own frames (needs ffmpeg)The examples are FoldKit's own example apps (weather, kanban, snake…), ported with their tests, plus showcases written here: EXAMPLES.md.
In the demo, click a row to select it and switch theme to swap the whole
token set live; THEME=paper bun run demo starts on the light set.
bun build --compile app.ts makes one executable. Ship gpuix's .node file
next to it and set NAPI_RS_NATIVE_LIBRARY_PATH to it: gpuix's loader picks its
platform package dynamically, so the compiler can't embed it on its own.
Pin gpuix exactly (@gpuix/native and its platform package, same version),
as gpuix's README insists.
| Process start → first frame | ~480 ms |
| Memory | ~186 MB, one process |
| Click → updated frame | 7–34 ms (style resolution dominates) |
| 60 Hz animation | 59.75 fps; change → frame median 2.2 ms, 95th 6.4 ms |
- Ship it: the packages already build to ESM and type declarations
(Building installable packages). Still to
come: publishing them to npm, and a
--compilerecipe that embeds the.nodefile. - Layout and overlays. The adapter already reads GPUI's last layout,
exposes
ResizeObserverand scroll positions, and supportsanchoredpopovers andvirtual-list. Fresh layout still depends on painting; geometry queries can block when GPUI stops answering. Virtual lists have their own layout limits. See the adapter guide's geometry and native elements. - Inline text. GPUI has blocks and flex, not inline formatting; mixed runs
(
<p>a <b>b</b> c</p>) become a wrapping row today. - Text editing. Native inputs, controlled values, focus synchronization,
keyboard navigation and modal focus scopes are implemented. IME composition
and
selectionchangeevents remain gaps, and password fields are refused because gpuix has no masked input. - CSS coverage and speed. The direct adapter avoids happy-dom's
getComputedStylepath and supports live viewport units and auto-margin centering. Remaining limits include sibling/structural selectors, pseudo-elements, ancestor state selectors, rotation/scaling transforms, and native CSS transitions and keyframes. Keep qualification and performance budgets separate from retained-tree checks; see CAPABILITIES.md. - Mirror maintenance. happy-dom's cached descendant selector matches are not invalidated when an ancestor's attribute changes; the mirror clears caches when it restyles. This comparator limitation is separate from the direct adapter's sheet.
- Accessibility. Roles and accessible names already reach AccessKit. Checked state is value text and disabled state is missing; real screen reader qualification remains future work.
- More tests. The CI workflow runs the adapter, components and mirror comparator headless, and real-GPUI tests on macOS. TESTING.md lists coverage and future layers (browser conformance, performance budgets, more platforms). Linux headless results do not qualify native Wayland behavior.
- Theming seams listed in THEMING-SEAMS.md (per-element blur, multi-stop gradients, colour transitions…).
Releases are off until a maintainer sets the repository variable
RELEASES_ENABLED to true (Settings → Secrets and variables → Actions →
Variables). Until then, merging to main publishes nothing: the Release
workflow is skipped.
Changesets manages the repository source release using the root
foldkit-native version and CHANGELOG.md. A GitHub Release named
foldkit-native@<version> points to the exact main commit whose CI passed;
GitHub supplies source archives for that tag. These are source snapshots,
not built npm packages or compiled native binaries. Root, adapter and UI
remain private: true and share the root's version (npm run version:packages
keeps the subpackages and the adapter's foldkit-native peer in step). The
root repository version is not a promise of independently distributable
adapter/UI packages.
Use Node 22.22.3 (npm 10.9.8) and Bun 1.3.9 for the release tools:
npm ci
npm run changeset # choose foldkit-native and write the change summary
npm run check:release # isolated version fixture and mocked GitHub controlsAfter a change lands on main and its existing CI workflow passes, Release
creates or updates changeset-release/main with npm run version:packages.
Review the version and changelog before merging that PR. The version command
consumes pending changesets and updates the root version in package.json and
package-lock.json; it does not publish or tag. The successful main CI after
merging the version PR permits npm run release:github to create the tag and
GitHub Release. Each future change needs a new changeset to advance the version.
The workflow must be present on the default branch; Actions must allow its
GITHUB_TOKEN to create pull requests. This setup changes neither setting,
and in this repository the second one is off: turn it on along with
RELEASES_ENABLED, or the first version PR fails to open.
When that token opens, updates or reopens the version PR, GitHub creates
pull-request CI runs that require approval. A maintainer with write access
must select Approve workflows to run in the PR's merge box before those
checks execute (GitHub token behavior).
Merge only after normal review and required checks. Token-triggered push
and other PR activity events do not start CI. No extra token, npm secret,
npm environment, registry authentication or OIDC permission is needed here.
Release execution rejects failed/non-push/unrelated/fork CI, checkout drift
and events whose tested SHA is no longer current main. All releases serialize
through release-main, and current-main/CI identity is rechecked immediately
before tag and release writes. GitHub has no atomic main-ref-and-release
transaction: main advancing after a tag write causes the next check to fail,
leaving the exact tested tag without a release. A retry at that same current
SHA can finish it. If main has advanced, the older incomplete tag needs a
maintainer's explicit recovery decision; automation never retags or deletes it.
Completed consistent releases are no-ops on duplicate events or later commits
without a new version; conflicting tag/version/changelog/release metadata
fails rather than overwriting published history. API/auth/network failures
remain failures. No CI artifact or fork code is consumed by the release flow.
npm run release:github -- --dry-run still requires a genuine successful
current-main workflow event and checkout, but prints the planned GitHub work
without writing tags/releases. The safe check:release tests use temporary
version fixtures and mocked APIs; they do not publish. CI checks the same
controls while retaining all existing unit, mirror, static, native and
artifact gates. Workflow failures are reported normally, with no native skips
or timeout relaxation to make publication succeed.
npm publication is a separate future step: choose public
package boundaries and shared-style ownership, align package versions/peers,
emit and qualify JS/types/exports in clean supported Node/Bun consumers,
verify packed licences, then explicitly approve private-flag changes and
registry credentials/OIDC. Only after those gates should a distinct npm
publish command and permission be added. Do not substitute changeset publish
for this GitHub-only release command today.
Based on the effectmq release workflow, Changesets configuration and Changesets action custom publishing.
FoldKit Native is MIT licensed (LICENSE). Its examples include FoldKit's example apps, MIT licensed, © 2025 Devin Jameson (examples/FOLDKIT-LICENSE); each ported file says so. Otherwise it depends on, and does not copy code from:
| Project | Licence | Used for |
|---|---|---|
| FoldKit and @foldkit/ui | MIT | The framework this renders (peer dependency) |
gpuix (@gpuix/native, incl. its GPUI build) |
Apache-2.0 | GPUI bindings and the native renderer |
| happy-dom | MIT | The mirror comparator's DOM |
| Effect | MIT | FoldKit's runtime (peer dependency) |
| Tailwind CSS and Lightning CSS | MIT, MPL-2.0 | Building the examples' CSS (dev only) |
| clsx, fractional-indexing | MIT, CC0-1.0 | Used by FoldKit's example apps (dev only) |
All are compatible with MIT. If you distribute a compiled binary that embeds gpuix's native library, include its Apache-2.0 licence and notices with it.
Contributors run npm ci, then npm run pack:packages -- /absolute/output/path.
The command builds each package separately with TypeScript, then uses standard
npm pack. Tarballs contain compiled ESM, declarations, notices and package
guides. Effect, FoldKit and gpuix stay external so an application shares one
copy of their runtime services. CSS is exported as strings (primitivesCss,
nativeDefaultsCss, uiCss); there is no missing external stylesheet or asset.
npm run check:packages packs, installs into a fresh temporary consumer and
checks imports, types, input and disposal. Node 22+ runs build/pack and the
headless consumer checks; Bun 1.3.9+ is the supported native app runtime.
For TypeScript with full dependency declaration checking, use @types/node@25.9.9
or newer: happy-dom 20.14.5 uses a stream type absent from Node 22 typings.
All three packages use the same version. npm run version:packages consumes
Changesets for the root and synchronizes the subpackages and adapter peer.
GitHub source releases (above) are stable versions only, require successful
exact-main CI, and are off until RELEASES_ENABLED is set. For test
artifacts, use the explicit pack command; it neither tags nor publishes
anything.
private: true remains an intentional publication guard. The package contents
and exports are ready for npm installation, but publishing to the registry is
an explicit maintainer step: remove that guard for the packages to publish,
choose the public scoped access and a prerelease tag such as test, build and
check the tarballs, then publish the reviewed files. No postinstall build runs.
Native binaries come from gpuix's optional platform packages, not these archives.
macOS Apple silicon and Linux x64 GNU are the qualified platform families;
Linux needs a Wayland/X11 display and gpuix's system libraries. Windows is
unqualified. Creating a native window still has the lifecycle and capability
limits described in the adapter guide.