gpuix-svelte README

The project's README from github.com/khromov/gpuix-svelte. As one markdown file: README.md.

gpuix-svelte

Work in progress. Experimental — built on Svelte's unreleased custom renderer API. Tested on macOS / Windows, also compatible with Linux.

[!IMPORTANT] Needs Node.js >= 24 or Bun >= 1.4.0. The liquid-glass FFI demo alone needs Node 26.1, which is where the built-in node:ffi it drives its ObjC shim through landed (Bun uses bun:ffi).

Svelte custom renderer for GPUI (Zed's GPU-accelerated UI framework), via @gpuix/native. Native desktop windows from ordinary Svelte components — no webview.

What does it look like?

The four demos — Hacker News, tic-tac-toe, the liquid-glass control center and the counter — each in its own native window.

The four gpuix-svelte demos running as native macOS windows

Try it

No Rust or other toolchains needed — the native binary comes prebuilt from npm.

git clone https://github.com/khromov/gpuix-svelte
cd gpuix-svelte
npm install
npm run demo              # all four demos at once
npm run demo:counter      # counter — edit examples/counter/Counter.svelte and save to hot-reload
npm run demo:tictactoe    # tic-tac-toe with score tracking
npm run demo:hn           # Hacker News reader (live data, scrollable list)
npm run demo:glass        # liquid-glass control center (GPUI's blurred translucent window)
npm run demo:glass-ffi    # same app on REAL Liquid Glass — NSGlassEffectView via FFI
                          # (macOS 26+; falls back to the window blur elsewhere)
npm run demo:styling      # styling playground — which CSS text reaches GPUI and which is dropped
npm run demo:web          # tic-tac-toe in a browser, on GPUI's WebAssembly/WebGPU build (Bun only)
npm run tutorial          # interactive onboarding guide — 12 steps with live samples and quizzes
npm run brain             # Substrate, a "second brain": notes, links, images and voice memos,
                          # searched by meaning, keyword and image content with on-device models,
                          # plus an OpenAI-compatible chat over it all. Bun only; run
                          # `npm run brain:install` first — see examples/second-brain/README.md
npm run brain:compile     # Substrate as dist/Substrate.app (macOS), models and all
npm test                  # headless renderer tests

New here? npm run tutorial (or bun run tutorial) opens a guided walkthrough of the renderer that is itself a gpuix-svelte app: each step pairs an explanation and a diagram with the source of a small component and that component running live, and ends with a quiz.

Every command has a Bun twin under a bun: prefix — npm run bun:test, npm run bun:demo, npm run bun:demo:counter, and so on. They run the same entry points through Bun, which gets the .svelte loader as a --preload instead of an --import. Dependencies still come from npm install either way; there is one lockfile, and CI runs both runtimes.

The one exception is Substrate (npm run brain), which is built on Bun's own APIs — bun:sqlite, Bun.spawn IPC, Bun.Image, HTMLRewriter, bun:ffi — and shows what a complete application on this renderer looks like: a hand-rolled router, .svelte.ts state modules that survive hot reloads, light and dark themes as one set_css_vars() palette, the package's Scroller and Portal, a background worker process for transformers.js, and OS integrations for everything GPUI has no API for.

Build a standalone binary

Bun can compile an example into one executable that runs without Node, Bun or node_modules:

npm run compile        # tic-tac-toe → dist/tictactoe (dist\tictactoe.exe on Windows)
npm run compile:app    # macOS: additionally wraps it as dist/Tic-tac-toe.app

The result is ~80 MB — the Bun runtime, the Svelte runtime and the 17 MB GPUI addon. It is built for the machine it runs on: run the same command on macOS (arm64), Linux (x64) or Windows (x64) to get that platform's binary. There is no cross-compiling, since npm only installs the addon prebuilt for the host.

The output is unsigned by default, and macOS blocks a downloaded unsigned copy until it is allowed under System Settings → Privacy & Security. See Signing.

Run it in a browser

The same components run on the web, because GPUI itself compiles to WebAssembly and paints a WebGPU canvas. This is not a DOM renderer — no HTML elements are produced — so what you get is the desktop app, pixel for pixel, inside a <canvas>:

npm run demo:web                  # → http://localhost:4173
npm run demo:web -- --production  # minified, Svelte's production runtime

examples/tic-tac-toe/web.ts is the entry, and it is the same two lines as the desktop one — the component, the renderer and your styles are unchanged. Only the plumbing differs:

Signing

macOS

compile signs when CODESIGN_IDENTITY names a Developer ID Application certificate in your keychain; compile:app also notarizes and staples the bundle when NOTARY_PROFILE names a notarytool keychain profile, and leaves dist/Tic-tac-toe.zip ready to ship. One-time setup:

  1. Create an app-specific password at https://account.apple.com → Sign-In and Security → App-Specific Passwords.

  2. Store it under a profile name, with the team ID from your certificate:

    xcrun notarytool store-credentials notary --apple-id [email protected] --team-id TEAMID --password xxxx-xxxx-xxxx-xxxx
    

    A 403 "required agreement is missing or has expired" means the Account Holder has to accept the current Program License Agreement at https://appstoreconnect.apple.com/agreements; it can take a while to propagate after that.

  3. Put both variables in a .env at the repo root — gitignored, and Bun loads it when it runs the script — so plain npm run compile:app signs from now on:

    CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)"
    NOTARY_PROFILE=notary
    

If Apple rejects a submission the script prints the submission output and exits; xcrun notarytool log <submission-id> --keychain-profile notary has the reason.

Use in your own project

npm install gpuix-svelte

That is the whole install. The renderer only works with one specific build of Svelte's unreleased custom-renderer branch, so the package ships that build inside itself (as a bundled dependency, under node_modules/gpuix-svelte/node_modules/svelte) and its loaders resolve every svelte import to that copy — from your .svelte files, your .ts files and the package alike. Do not add svelte to your own dependencies: the registry's svelte has the same version number and none of the API, and the package would ignore it anyway. Bun and pnpm work the same way.

starter/ is the smallest complete project — copy it and npm install.

// app.ts
import { render_hot } from "gpuix-svelte";

render_hot(new URL("./App.svelte", import.meta.url), {
  title: "Hello GPUI",
  width: 820,
  height: 560,
});

Run it through the package's bin:

npx gpuix-svelte app.ts          # Node
npx gpuix-svelte --bun app.ts    # Bun

Three things have to be true before your entry module resolves, and the bin does all of them: Svelte must be resolved with the custom-renderer condition (without it svelte is its server build and mount() doesn't exist), the .svelte loader must be installed, and on Node the package's TypeScript sources need tsx (it ships as a dependency — Node's own type stripping refuses to run under node_modules). Spelled out, the bin runs

node --conditions custom-renderer --conditions development --import tsx --import gpuix-svelte/register app.ts
bun  --conditions custom-renderer --conditions development --preload gpuix-svelte/plugin app.ts

which you can run by hand instead (on Bun, preload = ["gpuix-svelte/plugin"] in a bunfig.toml replaces the --preload; tsx has to come before gpuix-svelte/register, or it falls back to off-thread hooks the .svelte loader can't chain with, and a bare --import tsx only resolves where your package manager hoists it — the bin resolves its own copy). Flags before the entry go to the runtime (gpuix-svelte --experimental-ffi app.ts); arguments after it go to your script. Plain JavaScript entries work too; .ts is what the examples use.

The package ships TypeScript sources, not declaration files, so to typecheck your own code against it tsc needs "allowImportingTsExtensions": true (which implies "noEmit": true — a tsx or bundler workflow), @types/node, a paths entry pointing at the bundled Svelte (it is nested inside the package, where tsc would not look from your files) and a *.svelte module declaration — starter/tsconfig.json and starter/svelte.d.ts are the copy-and-paste versions:

// tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext", "strict": true, "noEmit": true, "allowImportingTsExtensions": true,
    "customConditions": ["custom-renderer", "development"], "types": ["node"],
    "paths": {
      "svelte": ["./node_modules/gpuix-svelte/node_modules/svelte"],
      "svelte/*": ["./node_modules/gpuix-svelte/node_modules/svelte/*"]
    }
  }
}

See HOWTO.txt for a few more details and troubleshooting notes.

Styling

There is no CSS engine. The style attribute is parsed into a plain object and sent to GPUI, whose layout is flexbox in logical pixels. <style> blocks work for class rules, compiled the same way.

<div class="btn" style="padding: 8px 16px; border-radius: 8px">Click</div>

<style>
  .btn { background-color: #313244; color: #cdd6f4; }
  .btn:hover { background-color: #45475a; }
</style>

What works

What doesn't work

Working, standard CSS semantics

Values are logical pixels unless noted. Only the listed keywords are recognised.

  • Layout: display: flex | grid; flex-direction: row | column; flex-wrap: wrap | wrap-reverse | nowrap; flex-grow, flex-shrink, flex-basis; gap, row-gap, column-gap.
  • Alignment: align-items: center | start | end; align-self: center | start | end | stretch | baseline; align-content: center | start | end | space-between | space-around | space-evenly | stretch | normal; justify-content: center | start | end | space-between | space-around (the flex- prefixed forms too; no space-evenly for justify-content).
  • Sizing: width, height, min-width, min-height, max-width, max-height in px, % or auto.
  • Spacing: padding, margin and their four sides, with 1 to 4 value shorthands.
  • Position: position: relative | absolute; top, right, bottom, left; inset.
  • Overflow: overflow, overflow-x, overflow-y as hidden | scroll.
  • Paint: background-color, color, border-color in any CSS colour syntax; opacity; border-width and its four sides; border-radius and its four corners, with 1 to 4 value shorthands.
  • Text: font-size, font-family, font-weight (100–900, normal, bold); text-align: left | center | right | start; line-height in px; white-space: nowrap | normal; text-overflow: ellipsis.
  • Interaction: cursor with the CSS keyword set (pointer, text, grab, grabbing, move, crosshair, not-allowed, the *-resize family, copy, alias, context-menu, default); pointer-events: none | auto; user-select: none; :hover and :active rules in <style>.
Working, non-standard CSS semantics

Accepted, but not what CSS would mean by it.

  • Unitless numbers are pixels: padding: 12, font-size: 18.
  • grid-template-columns: 3 and grid-template-rows: 2 take a count of equal tracks, not a track list. grid-column-min and grid-row-min (min-content | max-content) set each track's minimum; neither is a CSS property.
  • justify-content: between | around are aliases for space-between and space-around.
  • position: fixed lays out exactly like absolute; there is no viewport to fix to.
  • text-overflow: ellipsis-start truncates from the left.
  • line-clamp: N works unprefixed, without display: -webkit-box.
  • font-weight also takes thin, extralight, light, medium, semibold, extrabold, black and any number from 1 to 1000.
  • hover="..." and active="..." are attributes that take the same CSS text as style and apply only while the pointer is over, or pressing, the element. CSS cannot write a :hover rule inline; here <div style="color: #ccc" hover="color: #fff"> is the same as .x { color: #ccc } plus .x:hover { color: #fff }. If both exist, the attribute wins.
  • selection-color sets the text-selection highlight; CSS has no equivalent.
  • With pointer-events unset, an element that paints a background or is position: absolute blocks clicks to whatever is behind it, and mouse events never bubble. Put hitbox="self" on the clickable element: the renderer then gives every descendant without a listener of its own (<img> and <svg> included; inputs, scroll containers and focusable elements excepted) pointer-events: none, so badges and icons pass the click through, while a child with its own handler keeps its hitbox. Setting pointer-events yourself still wins.
  • onclick is the primary button alone, as in the DOM. A right or middle click is onauxclick, where e.isRightClick tells the two apart and e.x / e.y are window coordinates, so a menu rendered through <Portal> can be placed straight at them. macOS's ctrl+click is not routed there — it arrives as an ordinary onclick with e.modifiers.ctrl, so a handler that wants the platform's secondary click has to check for it. hitbox="self" shields aux clicks like any other.
  • Animation goes through the motion={{ initial, animate, transition }} prop (left, top, width, height, opacity, border-radius), not transition.

npm run demo:styling shows all of these side by side.

Theming with CSS variables

Class rules are compiled ahead of time, but a var() in one resolves at runtime against a map you control, so a palette is one object and a theme switch is one call:

<script>
  import { set_css_vars } from 'gpuix-svelte';

  const LIGHT = { surface: '#fbf7ef', ink: '#2a251f', border: '#e2d8c4' };
  const DARK = { surface: '#231f1b', ink: '#ece3d3', border: '#36302a' };
  let { dark } = $props();

  $effect(() => set_css_vars(dark ? DARK : LIGHT));
</script>

<div class="card">…</div>

<style>
  .card { background-color: var(--surface); color: var(--ink); border-color: var(--border); }
  .card:hover { border-color: var(--ink); }
  .badge { padding: var(--badge-pad, 2px 6px); }
</style>

set_css_vars restyles exactly the elements whose style read a variable, in one batch, and a call whose values are all already in force is a no-op. A var() works on any property, pixel-only ones included, and inside inline style= too. var(--name, fallback) uses the fallback while the variable is unset; without one the declaration is dropped, and the name is warned about once when the frame that read it ships — the $effect above runs after Svelte has styled the tree, so variables it defines are never reported. Keys are accepted with or without the --. Substrate (examples/second-brain/lib/theme.ts) is the worked example: one palette object per mode, handed over from App.svelte in an $effect.

Components

Two .svelte files ship in the package and compile through your loader like your own:

<script>
  import Scroller from 'gpuix-svelte/components/Scroller.svelte';
  import Portal from 'gpuix-svelte/components/Portal.svelte';
</script>

Scroller — GPUI paints no scrollbar, so this is a scroll column with a drawn thumb: it measures its painted bounds and scroll offset, draws the thumb in a gutter and drags it on an overlay (GPUI doesn't capture the pointer, so a move with no button held counts as the release). Props: gap and pad for the content column, grow (its flex-grow), scroll={false} to clip without a thumb, follow to keep the bottom in view while content grows (a streaming reply), and testid. Colours come from var(--scroller-thumb) and var(--scroller-thumb-hover), with greys as fallbacks.

A plain scroll column still lays out and paints every child on every frame, on screen or not, so a long list gets slow in proportion to its length. virtual renders GPUI's native <virtual-list> instead: every direct child is one row (wrap each item in a div with width: 100%, since rows size to their content and the list has no gap), GPUI builds and paints only the rows near the viewport, estimate is the height hint for rows it has not measured yet, and follow becomes the list's own tail-following. The thumb then works in rows rather than pixels, from getListScrollTop and the visibleRange event, and a drag scrolls with scrollToItem. Substrate's timeline is the example: 50 cards went from ~

gpuix-svelte (khromov/gpuix-svelte)

Searching Database…

这是什么?gpuix-svelte 是一个自定义渲染器,让普通的 Svelte 组件通过 GPUI(Zed 的 GPU 加速 UI 框架)绘制原生桌面窗口,完全不涉及 webview。
这是一个桌面框架,还是只是 Svelte 的渲染后端?它是安装到 Svelte 项目中的一个渲染器包/库;它把 Svelte 尚未发布的自定义渲染器目标从 DOM 换成 GPUI,让你的组件变成原生窗口。
怎么把它加到我自己的项目里?复制 starter/ 文件夹(最小的完整项目),运行 npm install,然后通过该包自带的 bin 启动你的入口文件,例如 gpuix-svelte app.ts。
我需要安装 Rust 或其他原生工具链吗?不需要。原生 @gpuix/native 插件从 npm 以预构建形式发布,所以你只需要 Node.js 24+ 或 Bun 1.4.0+——无需 Rust 或编译器配置。
它够稳定吗,能用来构建真正的应用吗?不够——它明确是一个基于 Svelte 未发布自定义渲染器 API 的实验性进行中作品,因此随着上游 API 的变化,出现破坏是意料之中的。
它实际能在哪些平台上运行?桌面构建在 macOS 和 Windows 上经过测试,也兼容 Linux;由于 GPUI 可编译为 WebAssembly 并通过 WebGPU 绘制,同样的组件还能在浏览器中运行。
我能交付独立的可执行文件,而不要求用户装 Node 或 Bun 吗?可以,Bun 能把示例编译成一个自包含的二进制文件,运行时不需要 Node、Bun 或 node_modules。产物约 80 MB,由 Bun 运行时、Svelte 运行时和 17 MB 的 GPUI 插件组成,而且由于不支持交叉编译,必须在每个目标平台上原生构建。
没有 DOM 和 CSS 的话,样式是怎么工作的?没有 CSS 引擎:组件上的 style 属性会被解析成普通对象并直接发送给 GPUI,由 GPUI 以逻辑像素按 flexbox 布局一切,主题值则通过 setcssvars() 调用并使用 var(--name) 引用来切换。
这个包开箱提供了哪些 UI 构建模块?它自带两个现成的 Svelte 组件:Scroller,一个带自绘滑块的滚动列(因为 GPUI 不绘制原生滚动条);以及一个只渲染视口附近行的虚拟列表,让长列表保持流畅。
把应用交给别人之前需要签名吗?分发到 macOS 时需要——编译出的二进制默认未签名,macOS 会阻止运行下载来的未签名副本,直到你在系统设置中明确允许,因此该项目包含了通过 Developer ID 和 notarytool 进行签名与公证的流程。
Creator
khromov
Size
16.4 MB
Stars
223
Age
1mo
Commits · 90 Days