Immediate-mode WebGPU UI toolkit extracted from the union editor runtime.
It bundles the pieces needed to build a browser-native editor UI on top of WebGPU:
ui_renderer— a batched WebGPU renderer for rectangles, rounded rects, SDF text (Lato main text and jb_mono monospace text in a shared atlas, PingFang SC for Chinese text), images, and the HSV color picker panels.ui_widgets— an immediate-mode widget layer (buttons, toggles, sliders, dropdowns, text/number inputs, color pickers, scroll regions, menus) drawn throughui_renderer.ui_icon— a set of vector icons (file, folder, folder_open, chevrons, search, settings, …) composed fromui_rendererdraw commands and baked once into a single cached 512² atlas texture (32² per cell), then drawn tinted to any colour. See Icons.dock— a docking layout engine: split/leaf trees, tab drag-and-drop, drop targets, and (de)serialization.dock_system/window_system— ready-to-use workspace systems built ondock/window: a docked split workspace and a floating desktop-style window manager, both part of core so third-party projects can build directly on them. See Workspace systems.theme— palette/CSS-variable theming withload_theme,apply_theme,theme_color,theme_rgba,pack_color, andhex_to_normalized_rgba.app_registry— installable apps described by a JSON manifest: install/uninstall, persistence, and update checks against each app'sshipping_path. Seedashboard.plugins— opt-in, higher-level drop-in components (file_browser,graph,node_graph,im_dialog,code_editor,dashboard,box3d_demo,asset_hubcloud drive browser/uploader,material_audit,webtixWebGPU path tracer) packaged so other projects can reuse them piecemeal. See Plugins.storage— a browser-only cloud storage abstraction (cloud_storage_provider) with a Google Drive backend, used by the Asset Hub browser/uploader. See Asset Hub.physics— the default browser 3D physics module, backed by Box3D WASM. It provides dynamic rigid bodies, gravity/stepping, static Box/Sphere/height-field collision, and swept capsule movement through an engine-neutral API.
Box3D is the toolkit's default 3D physics backend. Applications import the
engine-neutral surface from @liamlangli/ui/physics; the Emscripten module and
WASM asset stay private to that package boundary.
import{physics_world,quaternion_from_euler}from'@liamlangli/ui/physics'constworld=awaitphysics_world.create()world.reset()world.add_box({x: 0,y: -0.5,z: 0},quaternion_from_euler({x: 0,y: 0,z: 0}),{x: 5,y: 0.5,z: 5},)world.set_gravity({x: 0,y: -9.81,z: 0})constbody=world.add_dynamic_sphere({x: 0,y: 3,z: 0},0.5)world.step(1/60,4)consttransform=world.body_transform(body)constmoved=world.move_capsule({x: 0,y: 2,z: 0},-0.7,0.7,0.3,{x: 0,y: -3,z: 0},)The checked-in WASM is pinned to a Box3D commit and can be regenerated on macOS 26+ with Apple container:
npm run wasm:box3d
npm run test:physicsThe build uses the pinned emscripten/emsdk:4.0.13 image under Rosetta, so no
host Emscripten installation is required. WebTIX remains the rendering/path-
tracing plugin; Box3D replaces the toolkit's default 3D physics implementation,
not its renderer.
An interactive playground lives in preview/ and is wired up with
Vite. It boots the renderer and lays the whole demo out as a desktop driven by
the core window_system: the docked workspace (Explorer, Editor, Console,
Metrics) is a single "Demo Editor" app window powered by dock_system, and the
other views (Widgets gallery, Icons, Graph, Node Graph, About, Chat) float as
their own windows — every pixel is drawn on the GPU.
Open View ▸ Apps ▸ Box3D Physics (or launch it from the Dashboard) for an interactive WASM rigid-body playground with falling boxes and spheres, a ramp, stacking collisions, pause/reset controls, and click-to-drop interaction.
npm install
npm run dev # local dev server
npm run build # production build → dist/ (GitHub Pages base = /ui/)Requires a WebGPU-capable browser (recent Chrome/Edge, or Safari Technology Preview). The page shows a graceful fallback otherwise.
The Asset Hub window (View ▸ Apps ▸ Asset Hub) is a Google Drive viewer
and needs a Google OAuth client id: copy .env.example to .env.local and
set VITE_GOOGLE_CLIENT_ID (setup steps in
Asset Hub). Without it the
panel shows a configuration message — everything else works as before.
.github/workflows/deploy-pages.yml builds the preview and deploys it to GitHub
Pages on every push to main (or via Run workflow). Once enabled
(Settings → Pages → Source: GitHub Actions) the demo is served at
https://liamlangli.github.io/ui/. The Pages sub-path is injected at build time
via the BASE_PATH env var, so forks deploy under their own repo name
automatically.
dock_system and window_system are part of the core package: they are the
two ready-to-use workspace shells third-party apps are expected to build on.
Each is a self-contained immediate-mode component: it owns its drawing and
input handling and takes your ui_renderer, a theme_definition, and the
per-frame ui_input_snapshot.
import{dock_system,window_system}from'@liamlangli/ui'The core dock module is pure layout math; dock_system is the rendering +
input glue around it. It draws tab bars, splitters, the drag ghost and drop
overlay, drives tab activation / drag-to-reorder / drag-to-split / splitter
resize, and hands each visible panel body back to you to fill.
constdock=newdock_system()// or new dock_system(my_saved_layout)// each frame, between renderer.begin_frame() and renderer.flush():dock.frame(renderer,theme,input,x,y,w,h,(panel)=>{// panel.{x,y,w,h} is the clipped body rect (physical px)if(panel.tab.id==='files')file_browser(renderer,theme,input,panel.x,panel.y,panel.w,panel.h,tree,fb_state)})dock.add_tab({id: 'log',title: 'Log'})// spawn/focus a tabconstsaved=serialize_dock_layout(dock.layout)The sibling of dock_system, for a desktop-style "window mode". The core
window module is pure layout state; window_system is the rendering + input
glue. Each view floats in its own frame with a header bar (title plus
minimize / maximize / close buttons), drag-to-move, drag-to-resize from any
edge or corner, and click-to-focus z-ordering. A rounded taskbar pinned to the
bottom lists the running views and shows a live clock; clicking a chip focuses,
restores or minimizes its window. The body callback hands back the same panel
shape as dock_system, so one render switch can drive both — let the user flip
between dock mode and window mode.
constwindows=newwindow_system()// or new window_system(my_saved_layout)// each frame, between renderer.begin_frame() and renderer.flush():windows.frame(renderer,theme,input,x,y,w,h,(panel)=>{// panel.{x,y,w,h} is the clipped body rect (physical px) — identical to dock_systemif(panel.tab.id==='files')file_browser(renderer,theme,input,panel.x,panel.y,panel.w,panel.h,tree,fb_state)})windows.add_window('log','Log')// spawn/focus a windowconstsaved=serialize_window_layout(windows.layout)By default (cache_bodies: true) only the focused window renders its body live
each frame; inactive windows have their geometry cached and replayed (see
Retained layers), so a workspace full of
windows costs roughly one live panel plus cheap buffer copies. Call
windows.invalidate(id) when an inactive window's content changes (the preview
does this for the Chat window when a message arrives).
Core provides a generic bottom-right task queue that is independent of Asset
Hub. The application owns one ui_task_queue_state; any subsystem with that
reference can send enqueue, update, complete, fail, cancel, or
source-scoped clear messages. Enqueued messages with a run callback execute
sequentially and receive an AbortSignal; messages without run represent
externally-managed work.
consttasks=create_ui_task_queue_state(()=>renderer.request_render())ui_task_queue_send(tasks,{type: 'enqueue',source: 'exporter',title: 'EXPORT · scene.glb',detail: 'Waiting to export',running_detail: 'Exporting…',run: async(signal,update)=>{update('Writing geometry…')awaitexport_scene({ signal })},})// Render once above the desktop; producers do not render their own progress.constbounds=ui_task_queue_bounds(tasks,safe.x,safe.y,safe.w,safe.h,scale)ui_task_queue_render(renderer,theme,input,tasks,bounds,scale)The row's Close button sends cancel, aborting managed work or invoking an
external task's optional on_cancel. Successful managed tasks disappear;
failed tasks remain until dismissed.
Import individual plugins from the @liamlangli/ui/plugins sub-path (or the
package root). Each is a self-contained immediate-mode component: it owns its
drawing and input handling and takes your ui_renderer (+ ui_widgets where
needed), a theme_definition, and the per-frame ui_input_snapshot.
import{code_editor,dashboard,file_browser,graph_canvas,node_graph,im_dialog}from'@liamlangli/ui/plugins'For the fastest first paint, import the core toolkit from @liamlangli/ui/core
(everything except the plugins) and pull the plugins in behind a dynamic
import('@liamlangli/ui/plugins') once the first frame is on screen. The
preview's ui_main.ts does exactly this: the window-system desktop renders
immediately with "Loading…" panel bodies, then swaps them live when the plugin
chunk arrives.
A scrollable, expandable file/folder tree. You own the file_node[] forest and
the persistent state; it reports selection / activation (double-click or Enter) /
expand-toggle.
constfb=create_file_browser_state()consttree: file_node[]=[{name: 'src',kind: 'dir',children: [{name: 'index.ts'}]}]constev=file_browser(renderer,theme,input,x,y,w,h,tree,fb,{default_expanded: true})if(ev.activated)open_file(ev.activated.name)The same file_browser function also supports the richer content-browser
pattern: a collapsible folder tree, breadcrumb, file search, list/grid modes,
host toolbar buttons, context-menu intents, and preview extension hooks. You own
the folder forest, current-folder entries, optional global search entries, and
each thumbnail via render_preview. Projects such as Union keep 3D image/model
preview rendering in their own code and hook it in through that callback.
constfb=create_file_browser_state('Project')constfolders: file_browser_folder_node[]=[{path: 'Project',name: 'Project',children: [{path: 'Project/Textures',name: 'Textures'}]}]constentries: file_browser_entry[]=[{path: 'Project/Textures/brick.png',name: 'brick.png',kind: 'file',type_label: 'TEXTURE'}]constev=file_browser(renderer,theme,input,x,y,w,h,folders,entries,fb,{toolbar: [{id: 'create',label: 'Create Asset'},{id: 'import',label: 'Import'}],render_preview: (entry,px,py,pw,ph)=>draw_thumbnail(entry,px,py,pw,ph),})if(ev.folder_selected)load_folder(ev.folder_selected)if(ev.entries_selected)highlight_selection(ev.entries_selected)if(ev.entry_activated)open_asset(ev.entry_activated.path)if(ev.toolbar_clicked==='create')open_create_menu()if(ev.context_requested)open_context_menu(ev.context_requested)List and grid entries support range multi-select: a plain click selects one
entry and drops the range anchor there, and Shift-click selects everything
between that anchor and the clicked entry, in view order. Every selected row and
card is stroked with a yellow rounded outline on top of its fill, so a
multi-selection stays legible against host thumbnails. Repeated Shift-clicks
re-adjust the same range, so the anchor stays put until the next plain click;
clicking empty space, switching folders, or a double-click into a folder clears
both selection and anchor. Shift-click never starts an entry drag, activates an
entry, or counts toward a double-click. The full selection lives in
state.selected_paths and is reported as entries_selected; entry_selected
stays the single entry under the cursor. Entry drag and context_requested
remain single-entry — a host that acts on the whole selection (a context-menu
delete, say) should read state.selected_paths and check whether the
right-clicked path is part of it. Hosts that select an entry programmatically
only need to assign selected_paths; the anchor follows the live selection.
A generic, content-agnostic node editor surface: a pannable / zoomable grid,
nodes with typed input/output pins, bezier wires, a marquee selection box and a
floating link draft. It owns all interaction — left-drag a node to move it (or a
marquee on empty canvas to select; Shift extends), drag from an output pin to an
input pin to connect, middle-drag to pan, wheel to zoom, right-click for a create
menu. You own the nodes/links arrays and describe each node through a
spec(node) → { title, inputs, outputs }, so the same canvas drives a shader
graph, render graph, material graph, … The plugin mutates node.x/.y on drag and
pushes to links on connect; events are returned so you can react.
import{graph_canvas,create_graph_state}from'@liamlangli/ui/plugins'importtype{graph_node_view}from'@liamlangli/ui/plugins'constgstate=create_graph_state()constnodes=[{id: 1,x: 20,y: 30,type: 'UV'},{id: 2,x: 240,y: 40,type: 'Output'},]constlinks=[{src_node: 1,src_pin: 0,dst_node: 2,dst_pin: 0}]functionspec(node: (typeofnodes)[number]): graph_node_view{// → host maps its node model to a title + typed pinsreturnnode.type==='UV'
? {title: 'UV',inputs: [],outputs: [{label: 'UV',kind: 'uv'}]}
: {title: 'Output',inputs: [{label: 'Base Color',kind: 'color'}],outputs: []}}// each frame, between renderer.begin_frame() and renderer.flush():constev=graph_canvas(renderer,theme,input,x,y,w,h,nodes,links,gstate,spec,{compatible: (out_kind,in_kind)=>out_kind===in_kind,// gate wire creationrender_body: (node,view,body)=>draw_inline_editor(node,body),// inline node content})if(ev.link_created)recompile()if(ev.link_removed)recompile()// pin click or alt-click on a wireif(ev.menu_requested)open_create_menu(ev.menu_requested)// { screen_x, screen_y, graph_x, graph_y }if(ev.delete_requested)remove_selected(gstate.selected)Wires are cut two ways: click a pin to drop every wire on it, or hold Alt and
click a wire to cut just that one (a spatial grid finds the wire under the
cursor without testing every link). Pan and the create menu need the middle /
right mouse buttons forwarded on the ui_input_snapshot (mouse_middle_down,
mouse_right_pressed), and Alt-cut needs the alt modifier; selection,
node-drag, marquee, wire-drag and zoom work with the base left-button + wheel
fields alone.
A self-contained node editor with a pannable / zoomable field of dots for a
backdrop, nodes that carry typed input/output slots, bezier wires, a marquee
selection box and a built-in right-click "add node" menu. Every connection is
type-gated: a wire is only created when the output slot's type is
compatible with the input slot's type (compatible defaults to exact match),
so a color output won't drop onto a vec3 input. Unlike graph (which keeps
node shape in a host spec callback over a line grid), node_graph stores
slots directly on the node, so adding a node or a slot is a plain data
mutation — use the add_node / add_slot helpers.
It owns all interaction — left-drag a node to move it (or a marquee on empty
canvas to select; Shift extends), drag from one slot to a compatible slot to
connect, middle-drag to pan, wheel to zoom, right-click for the create menu,
Delete/Backspace to remove the selection. You own the nodes/connections
arrays; events are returned so you can react.
import{node_graph,create_node_graph_state,add_node,add_slot}from'@liamlangli/ui/plugins'importtype{node_graph_node,node_graph_connection,node_graph_template}from'@liamlangli/ui/plugins'conststate=create_node_graph_state()constnodes: node_graph_node[]=[add_node('Input',20,40,{id: 'in',outputs: [{label: 'UV',type: 'vec2'}]}),add_node('Output',240,60,{id: 'out',inputs: [{label: 'Albedo',type: 'color'}]}),]add_slot(nodes[1],true,{label: 'Normal',type: 'vec3'})// append a typed slot in placeconstconnections: node_graph_connection[]=[]// templates populate the built-in right-click "add node" menu (omit to disable it):constnode_types: node_graph_template[]=[{type: 'Sample',inputs: [{label: 'UV',type: 'vec2'}],outputs: [{label: 'Color',type: 'color'}]},]// each frame, between renderer.begin_frame() and renderer.flush():constev=node_graph(renderer,theme,input,x,y,w,h,nodes,connections,state,{compatible: (out_type,in_type)=>out_type===in_type,// gate wire creation by slot type
node_types,})if(ev.connection_created)recompile()if(ev.connection_rejected)flash_warning(ev.connection_rejected)// incompatible typesif(ev.node_created)console.log('spawned',ev.node_created.title)if(ev.delete_requested)remove_selected(state.selected)Pan and the create menu need the middle / right mouse buttons forwarded on the
ui_input_snapshot (mouse_middle_down, mouse_right_pressed); selection,
node-drag, marquee, wire-drag and zoom work with the base left-button + wheel
fields alone.
A whole-screen launcher over the core app_registry: every installed app
appears as a grid tile (icon plate with the app name under it). Clicking a tile
launches the app, right-clicking opens a manage menu (Open / Check for Updates /
Update / Uninstall), and dragging an app description JSON onto the page
installs it. An app ships as a small manifest:
{
"id": "notes",
"name": "Notes",
"version": "2.1.0",
"description": "A tiny scratchpad app.",
"icon": "file_text",
"accent": "#3d6b4f",
"shipping_path": "apps/notes.json"
}shipping_path is the URL the manifest is served from — the registry re-fetches
it to check for updates (per-segment numeric version compare), so publishing a
newer manifest at the same path is all a vendor needs to do to ship an update.
Tiles show a badge while an update is pending.
import{app_registry,serialize_app_registry}from'@liamlangli/ui'import{dashboard,create_dashboard_state,dashboard_drop_target}from'@liamlangli/ui/plugins'constregistry=newapp_registry(localStorage.getItem(KEY))registry.on_change=()=>localStorage.setItem(KEY,serialize_app_registry(registry))registry.install({id: 'editor',name: 'Editor',version: '1.0.0',icon: 'code'},{builtin: true})constdash=create_dashboard_state()// drag-to-install: dropped .json files (or dragged manifest URLs) install into the registrydashboard_drop_target(canvas,registry,dash,{on_installed: (app)=>show_dashboard()})// each frame, drawn last so it covers the whole screen:constev=dashboard(renderer,theme,input,0,0,screen_w,screen_h,registry.apps,dash,{ icons })if(ev.launched)open_app(ev.launched)if(ev.uninstall_requested)registry.uninstall(ev.uninstall_requested.manifest.id)if(ev.check_updates_requested)registry.check_update(ev.check_updates_requested.manifest.id)if(ev.update_requested)registry.apply_update(ev.update_requested.manifest.id)if(ev.dismissed)hide_dashboard()Built-in apps (installed with { builtin: true }) have no shipping path and
can't be uninstalled from the menu by default. The preview wires the whole flow
up under View ▸ Apps ▸ Dashboard; drag
public/apps/notes_v1.json onto it to install an
app whose shipping path already serves a newer version, then right-click its
tile to update.
A chat surface with incoming/outgoing bubbles, avatars, author + timestamp captions, auto-scroll-to-newest, and an optional composer (text input + Send). It returns submitted text so you can append it to your own message array.
constchat=create_im_dialog_state()constmessages: im_message[]=[{author: 'Adam',side: 'left',text: 'Hi!',timestamp: Date.now()},]// widgets.begin_frame() must have run this frame (im_dialog uses the composer):constev=im_dialog(renderer,widgets,theme,input,x,y,w,h,messages,chat,{title: 'Adam · online',header_action_label: 'Clear',placeholder: 'Message Adam…',is_typing: adamIsTyping,typing_author: 'Adam',})if(ev.header_action)messages.length=0if(ev.sent)messages.push({author: 'Me',side: 'right',text: ev.sent,timestamp: Date.now()})The preview's Chat window wires this UI to a local Ollama
/v1/chat/completions endpoint (qwen2.5:7b-instruct) and includes repo plus
installed-plugin context in each request. Long conversations are summarized
before sending once they exceed the configured context budget.
CJK works once the Chinese atlas has loaded (see Chinese font loading).
A GPU-rendered, editable code editor: an optional folder/file tree, a
line-number gutter, selection highlight, blinking caret, mouse selection (click
/ drag / double-click word / triple-click line) and full keyboard editing
(typing, Backspace/Delete, Enter with auto-indent, Tab→spaces, arrows,
Home/End, PageUp/PageDown, Ctrl/Cmd+A, Ctrl/Cmd+C). You own the text model
(text_buffer) and the view state (code_editor_state).
Syntax highlighting is pluggable and language-agnostic: pass a per-line
tokenize function returning { kind, text } tokens — the toolkit ships a
neutral default palette and never bakes in a language. Wire a real tokenizer
(regex, a language server, a WASM lexer, …) from the host.
import{code_editor,create_code_editor_state,text_buffer}from'@liamlangli/ui/plugins'importtype{editor_token}from'@liamlangli/ui/plugins'constbuf=newtext_buffer('fn main() {}')consted=create_code_editor_state()functionmy_tokenize(line: string): editor_token[]{// → [{ kind: 'keyword', text: 'fn' }, { kind: 'whitespace', text: ' ' }, …]}// each frame, between renderer.begin_frame() and renderer.flush():constev=code_editor(renderer,theme,input,x,y,w,h,buf,ed,{tokenize: my_tokenize,// omit for plain (unhighlighted) textfile_tree: [{name: 'src',kind: 'folder',children: [{name: 'main.ts'}]}],// token_colors: { keyword: '#c678dd' }, read_only, font_px, tab_size, highlights, …})if(ev.changed)recompile(buf.get_text())if(ev.tree_activated)open_file(ev.tree_activated)The host forwards the same ui_input_snapshot the other plugins use; typed
characters arrive on typed_text and editing/navigation keys on the
key_* / ctrl / meta / shift flags (see Text view
for the full list). Token kinds are keyword, type, number, string,
comment, operator, identifier, punctuation, function, whitespace,
plain.
Drop or upload a material's maps — a base color map and/or a tangent-space
normal map (dropped filenames route automatically: *_normal*, *_nrm*,
*_n.*, … land in the normal slot) — then validate them on three preview
shapes:
- grid — the maps tiled endlessly on a flat plane (repeat-addressed sampler), so seams, borders and periodic patterns stand out immediately; a loaded normal map lights the plane so bump seams show too. A tile guides toggle draws 1px lines on the tile boundaries.
- sphere — a UV sphere: pole pinching, UV stretch and normal-map shading.
- cube — a rounded cube: flat faces with beveled edges, the classic trim/material check. A repeat control tiles the UVs ×1/×2/×4/×8 on the 3D shapes.
The grid pans and zooms through the shared core pan_zoom module (drag with
the mouse or one finger, wheel or two-finger pinch); the 3D shapes orbit on
drag and dolly on wheel / pinch. A full mip chain is generated for every
upload, and in the default auto mode mip sampling is enabled while the
texture is minified (grid zoom < 100%, and always on the 3D shapes, which
perspective-minify) — force it on / off from the toolbar to compare the
shimmer. The status line reports both map slots, POT-ness, the current view
and the live mip decision.
import{material_audit,material_audit_dom_target,create_material_audit_state}from'@liamlangli/ui/plugins'constma=create_material_audit_state()material_audit_dom_target(canvas,ma,{on_change: ()=>renderer.request_render()})// each frame, between renderer.begin_frame() and renderer.flush():constev=material_audit(renderer,widgets,theme,input,x,y,w,h,ma)if(ev.loaded)console.log(`${ev.loaded.slot} map: ${ev.loaded.name} (${ev.loaded.width}×${ev.loaded.height})`)The wheel-zoom / two-finger pan + pinch handling lives in core as
pan_zoom_apply / pan_zoom_drag (@liamlangli/ui → ui_pan_zoom); the
graph and node_graph canvases run on the same module.
A static asset browser (and light uploader) over the user's own cloud drive — a Google Drive integration, not a storage backend. The site stays a pure static frontend: the user clicks Connect Google Drive, authorizes in the browser through the Google Identity Services access-token flow (no client secret, no server round-trip), then picks their asset folder — conventionally named
asset_hub/
— in the Google Picker, and the panel browses its contents: folders open on
click with a asset_hub / characters / hero breadcrumb, images decode to
GPU-texture thumbnails, text/JSON/shader files preview inline, and everything
else (models, audio, video, binaries) shows a metadata card (name, MIME type,
size, modified time) with Download / Open-in-Drive buttons. File bytes stream
straight between Google and the page — there is no backend in the middle.
An Upload button sends local assets the other way: a .glb uploads as-is,
and a .zip containing a .gltf is unpacked in the browser (no dependency —
see src/storage/ui_zip_reader.ts) and the .gltf plus the buffers/images it
references upload together, in a new subfolder when there's more than one
file so the .gltf's relative URIs keep resolving. See
Uploading assets below.
Uploads and downloads send messages to Core's application-level task queue in the bottom-right corner rather than showing progress inside the Asset Hub panel. Each row has a Close action that removes queued work or aborts an in-flight Drive request; successful rows disappear automatically and failed rows remain until dismissed.
The app requests https://www.googleapis.com/auth/drive.readonly to read
existing assets and https://www.googleapis.com/auth/drive.file to upload
files created through the app. Google grants drive.file per item: selecting
a folder does not recursively grant its existing children, so it cannot power
a folder browser on its own. drive.readonly is therefore required and is a
restricted scope; public deployments must complete Google's OAuth verification.
The UI still limits browsing to the folder selected in the Picker. Its id is
remembered in localStorage, so later visits reopen it directly, and a
Change Folder button re-opens the Picker at any time.
The UI depends only on the cloud_storage_provider interface
(src/storage/ui_cloud_storage_provider.ts); every Google Drive API call
lives in google_drive_provider (src/storage/ui_google_drive_provider.ts),
so a Dropbox / iCloud / local-folder backend can implement the same interface
and reuse the panel unchanged (the write methods are optional — a provider
without them just doesn't get an Upload button).
The storage module is app-agnostic and also available on its own, without the
plugin chunk, as @liamlangli/ui/storage — see
Cloud storage without the Asset Hub.
import{asset_hub_drive,create_asset_hub_drive_state,google_drive_provider,load_cloud_config,}from'@liamlangli/ui/plugins'// `app_id` namespaces the localStorage token and root-folder keys, so two// apps on one origin never share a session. VITE_CLOUD_APP_ID overrides it.constcfg=load_cloud_config({app_id: 'ui',root_folder_name: 'asset_hub'})constprovider=cfg.google_client_id
? newgoogle_drive_provider({client_id: cfg.google_client_id,app_id: cfg.app_id,root_folder_name: cfg.root_folder_name,api_key: cfg.google_api_key,access: 'read_all',// browsing pre-existing folders needs drive.readonly})
: null// panel shows a clear configuration message when nullconsthub=create_asset_hub_drive_state(provider,{root_folder_name: cfg.root_folder_name,on_change: ()=>renderer.request_render(),// async work landed — redraw})// each frame, between renderer.begin_frame() and renderer.flush():constev=asset_hub_drive(renderer,theme,input,x,y,w,h,hub)if(ev.folder_opened)console.log('entered',ev.folder_opened.name)The OAuth client id comes from the Vite build environment. This repository's
preview app includes a public development client in .env.development, so
npm run dev works without local setup. To test with another Google Cloud
project, copy .env.example to .env.local and set:
VITE_GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.comTo create the client id:
- In Google Cloud Console create (or pick) a project and open APIs & Services → Credentials → Create Credentials → OAuth client ID.
- Choose Application type: Web application.
- Add your site's origins to Authorized JavaScript origins — e.g.
http://localhost:5173fornpm run devandhttps://<user>.github.iofor the deployed static site. (The token flow needs no redirect URI.) - Enable the Google Drive API under APIs & Services → Library.
- Configure the OAuth consent screen (app name, support email), add
.../auth/drive.readonlyand.../auth/drive.file, and add your account as a test user for local development.drive.readonlyis restricted, so a public production app must complete Google's OAuth verification process.
Optionally set VITE_GOOGLE_API_KEY with an API key (restricted to the
Google Picker API and your origins): the folder picker usually works with the
OAuth token alone, but Google rejects the dialog with a developer-key error
in some configurations — the key fixes that.
Notes on session handling: the access token lives in memory and is mirrored
to localStorage — lightly obfuscated (XOR + base64, not encryption — there's
no secret to keep it safe from someone reading this source, it just isn't a
plain bearer token sitting in devtools) — so a new tab or a full browser
restart resumes the session too, instead of reprompting on every visit. It
still expires after about an hour regardless of where it's stored, and any
expired/revoked token flips the panel into a Sign In Again state instead of
failing silently. The picked folder grant persists on the Google account
(revocable at
myaccount.google.com/permissions).
There is no client secret anywhere in the app, and signing out revokes the
token and clears local storage.
After a scope change, the stored token is discarded automatically. Connect again and approve the updated permissions. Empty folders then return a normal empty listing, while Drive API failures include their HTTP status and Google reason in the browser console without logging bearer tokens or upload bodies.
The storage module is not tied to the Asset Hub — nothing in it knows what an
asset is. Import it on its own as @liamlangli/ui/storage to give any app a
place to keep its own documents in the user's drive, no server involved.
Two things make it app-agnostic. app_id namespaces every persisted key, so
apps sharing an origin keep independent tokens and root folders. And access
picks the scope profile:
access | Scopes | Sees | Verification |
|---|---|---|---|
'app_files' (default) | drive.file | the picked folder + files this app created | none needed |
'read_all' | drive.readonly + drive.file | the whole drive, read-only | restricted scope; public apps must pass Google review |
An app that only reads back what it wrote should stay on 'app_files' — it
avoids the verification burden entirely. Only a browser over folders the user
filled in some other way needs 'read_all'.
Beyond reading, the interface covers the write half a document store needs:
upload_file (create), update_file (rewrite in place, keeping the id),
create_folder, and delete_file (which the Drive backend implements as a
move to trash, never a permanent delete). All four are optional, so a
read-only backend simply omits them. cloud_write_options.properties stores
small app-private key/value metadata with a file — Drive appProperties —
which is what lets a sync pass compare a record's version from a folder
listing instead of downloading it.
ui_cloud_folder.ts adds the provider-agnostic glue on top:
import{ensure_folder,google_drive_provider,index_folder,load_cloud_config,put_file,read_json,}from'@liamlangli/ui/storage'constcfg=load_cloud_config({app_id: 'my_app'})constdrive=newgoogle_drive_provider({client_id: cfg.google_client_id,app_id: cfg.app_id,root_folder_name: cfg.root_folder_name,})awaitdrive.sign_in()constroot=(awaitdrive.find_root_folder())??(awaitdrive.pick_root_folder!())if(root){constdocs=awaitensure_folder(drive,root.id,'documents')// Index once, then create-or-update each record through it — providers// allow duplicate names, so `put_file` resolves the name to an id first.constindex=awaitindex_folder(drive,docs.id)awaitput_file(drive,docs.id,'note.json',blob,{mime_type: 'application/json',properties: {updated_at: String(Date.now())},},index)}Puppet uses exactly this to move its IndexedDB projects and scenes to and from a Drive folder, with IndexedDB staying the working store and the user driving each transfer with an explicit Upload / Download button.
The Upload button (next to Change Folder, shown once a folder is open) accepts:
.glb— uploaded to the current folder as-is..zipcontaining exactly one.gltf— unpacked in the browser (src/storage/ui_zip_reader.ts, a dependency-free ZIP reader: central directory parsing by hand,DecompressionStream('deflate-raw')for inflation). The.gltf'sbuffers/imagesURIs are resolved against the archive's other entries and uploaded alongside it — into a new subfolder named after the zip when there's more than one file, so relative URIs keep resolving; directly into the current folder when the.gltfis self-contained (embeddeddata:buffers). A referenced file missing from the archive fails that upload with a clear message rather than landing a broken scene.
src/plugins/asset_hub/ui_asset_hub_upload.ts holds the planning logic
(pure, provider-agnostic); asset_hub_drive_dom_target(canvas, state) wires
the hidden file input and the same mobile gesture-relay pattern used by
asset_audit_dom_target (a file dialog only opens from a trusted pointer
event, not one raised from inside the render loop).
The webtix path-tracing engine,
migrated into the toolkit as a plugin and reimplemented from WebGL2 to
WebGPU. The original ran the integrator in a GLSL fragment shader and read the
BVH + geometry out of RGB float textures addressed with fract()/floor()
math; this version walks a packed storage-buffer BVH (array<bvh_node>,
32 bytes/node, O(1) random access — no texel addressing) from a WGSL shader and
accumulates progressively into an rgba16float ping-pong, presenting a tonemapped
texture the panel composites with draw_texture.
It shares the host GPUDevice (no second WebGPU context), ships built-in
procedural scenes (sphere, torus, box, spheres + ground), an orbit viewport and
a live Disney-material sidebar (metallic / roughness / specular / transmission /
subsurface / clearcoat / IOR / base colour). It is a wavefront integrator: a
persistent per-pixel ray queue is advanced by a bounded number of single-bounce
compute runs per frame, so frame cost stays roughly constant regardless of the
bounce budget while the image refines progressively. It asks the adaptive
renderer to keep ticking until it hits the sample budget, then idles — any
camera, material or scene change restarts accumulation.
import{webtix,create_webtix_state}from'@liamlangli/ui/plugins'constpt=create_webtix_state('sphere')// each frame, between renderer.begin_frame() and renderer.flush():webtix(renderer,widgets,theme,input,x,y,w,h,pt,{ scale })The TLAS builder, legacy BLAS builder and procedural geometry are also exported
standalone (build_tlas, build_tlas_scene, build_bvh, build_scene,
make_sphere, …) alongside the GPU engine (webtix_tracer), so a host can
trace mixed analytic/mesh scenes or keep using its own triangle mesh:
import{build_tlas,build_tlas_scene,webtix_tracer,default_material}from'@liamlangli/ui/plugins'constscene=build_tlas(build_tlas_scene('spheres'))// analytic spheres + finite ground planeconsttracer=newwebtix_tracer()tracer.init(device)tracer.set_tlas_scene(scene)consttexture=tracer.render_sample(w,h,{ eye, target, fov,bounces: 5,material: default_material(), env_top, env_bottom,env_intensity: 1})tracer.set_scene(build_bvh(positions, indices), positions, normals) remains
available for existing mesh-only callers; it is adapted to a one-instance TLAS
internally.
import{ui_renderer,ui_widgets,create_empty_ui_input,apply_theme}from'@liamlangli/ui'constrenderer=newui_renderer(canvas)awaitrenderer.init()constwidgets=newui_widgets(renderer)The renderer loads its Latin/monospace font atlas (assets/latin_mono.{json,webp}),
Chinese font atlas (assets/ping_fang_sc_regular.{json,webp}), and shader
(assets/ui.wgsl) via Vite ?url imports by default, so consumers are
expected to build with Vite (or an equivalent bundler that understands the
?url suffix). Font atlases can also be supplied as explicit URLs during
renderer initialization.
ui_widgets methods all take explicit (x, y, w, h) rects, which means panels
end up threading a manual cy += cursor between every call. stack_ui_layout
is a thin facade over ui_widgets that removes that bookkeeping: pick an axis
(vstack, hstack, or zstack), then each widget call consumes the next slot
and forwards to the underlying widget. The preview's Widgets gallery is
built entirely this way.
import{create_stack_ui_layout}from'@liamlangli/ui'conststack=create_stack_ui_layout(widgets)// create once, reuse each frame// A vertical column of labelled sections + controls — no x/y cursor math.stack.vstack(x,y,w,h,/* slot count */6,{gap: 12})stack.section(22,'THEME')consttheme=stack.dropdown('theme',{w: 200,h: 28},theme_names,theme_index)stack.section(22,'VOLUME')constvolume=stack.slider('vol',{w: w-60,h: 18},volume,0,1,true)// Need a row inside the column? Pull one slot's rect and nest a second stack.constrow=stack.next_rect(30)inner.hstack(row.x,row.y,row.w,row.h,2,{gap: 12})if(inner.button('ok',{w: 120,h: 30},'OK'))save()inner.button('cancel',{w: 120,h: 30},'Cancel')A numeric size fills the cross axis (full width in a vstack, full height in
an hstack); pass { w, h } for an explicitly sized slot. Alignment
(STACK_ALIGN_*), reverse, gap, and padding are all supported, and the
pure layout_stack_into / layout_*stack_into functions expose the same math
for callers that just want rects without the widget facade.
padding insets every child by the given amount on all four sides, and it is
applied regardless of alignment — alignment, gaps, and STACK_FILL are all
resolved inside the padded content box. Pass STACK_FILL (-1) for any width
or height to make that dimension stretch: on the cross axis it fills the padded
content extent, and on the main axis it absorbs the leftover space after
padding, gaps, and fixed-size siblings.
import{STACK_FILL}from'@liamlangli/ui'// A toolbar row: fixed buttons on the ends, a search box that eats the middle.// Reserving space around a main-axis fill needs the precomputed `sizes` buffer,// so the layout can see every slot up front and split the remainder correctly.constsizes=[80,STACK_FILL,STACK_FILL,STACK_FILL,80,STACK_FILL]// [w, h] per slotstack.hstack(x,y,w,h,3,{gap: 8,padding: 12, sizes })stack.button('back',undefined,'Back')// 80 wide, full padded heightstack.input_field('q',undefined,query,'Search…',state)// fills the remaining widthstack.button('go',undefined,'Go')// 80 wide// Without a `sizes` buffer the streaming facade can't look ahead, so a// main-axis STACK_FILL is greedy — it grabs all space from the cursor to the// content edge. That's the right tool when the fill slot is the last one:stack.hstack(x,y,w,h,2,{gap: 8,padding: 12})stack.button('add',{w: 80,h: STACK_FILL},'Add')stack.input_field('q',{w: STACK_FILL,h: 28},query,'Search…',state)// eats the restThe Chinese (PingFang SC) atlas is several MB, so it never blocks startup:
init() resolves as soon as the small Latin/monospace atlas is ready, and the
Chinese atlas is fetched asynchronously in the background. Until it arrives the
CJK slot is backed by a 1x1 transparent texture (CJK glyphs simply render
blank), and once it loads the next frame picks it up automatically.
// Skip the Chinese atlas entirely (no background fetch):awaitrenderer.init({chinese_font: false})// Load it on demand later (resolves once the atlas is ready):awaitrenderer.load_chinese_font()chinese_font defaults to true.
To overwrite the default PingFang SC atlas with your own font, supply a
language_font source — a name plus the URLs of the font's JSON metrics
(BMFont-style chars table) and its atlas image. The URLs are fetched as-is,
so a bundler asset URL, an absolute path, or a remote URL all work:
importmy_font_jsonfrom'./my_font.json?url'importmy_font_imagefrom'./my_font.webp?url'awaitrenderer.init({language_font: {name: 'My Font',json: my_font_json,image: my_font_image},})// Or swap fonts after init (passing a source always forces a reload):awaitrenderer.load_chinese_font({name: 'My Font',json: my_font_json,image: my_font_image})To overwrite the default Lato/jb_mono atlas with your own Latin font bundle,
supply a latin_font source during initialization. Its JSON metrics document
must be a bundle with FONT_MAIN and FONT_MONO faces plus the atlas image
URL:
importmy_latin_font_jsonfrom'./my-latin-font.json?url'importmy_latin_font_imagefrom'./my-latin-font.webp?url'awaitrenderer.init({latin_font: {name: 'My Latin Font',json: my_latin_font_json,image: my_latin_font_image,},})ui_widgets.text_view is a fully GPU-rendered, selectable and copyable
scrollable monospace panel — a drop-in replacement for a DOM <pre> used as an
output/console view. It supports mouse-drag selection, Shift+click extend,
double-click word and triple-click line selection, wheel + scrollbar and
keyboard (arrows / PageUp / PageDown) scrolling, Ctrl/Cmd+A select-all, and
Ctrl/Cmd+C copy via navigator.clipboard.
import{create_text_view_state,text_view_selected_text}from'@liamlangli/ui'constlog_state=create_text_view_state()constlines=[{text: 'compiling…',color: '#9aa'},{text: 'error: unexpected token',color: '#f55'},]// each frame, inside begin_frame()/end_frame():widgets.text_view('output',x,y,w,h,lines,log_state,{wrap: true})// read the current selection (e.g. for a context-menu "Copy"):constselected=text_view_selected_text(lines,log_state)// programmatic scroll:log_state.scroll_to_line=lines.length-1// applied next frameCopy and select-all need the relevant modifier/navigation keys forwarded on the
ui_input_snapshot (ctrl, meta, key_a, key_c, key_up, key_down,
key_page_up, key_page_down).
For overlays driven by raw pixel data (e.g. a parse/token visualiser that used
to live on a 2D <canvas> + putImageData), the renderer can create, update,
and draw RGBA textures, including nearest-neighbour ("pixelated") sampling:
consttex=renderer.create_texture(w,h,{filter: 'nearest'})renderer.update_texture(tex,rgba/* Uint8ClampedArray | Uint8Array */)renderer.draw_texture(tex,x,y,w,h)// sampler chosen at create timerenderer.destroy_texture(tex)The renderer keeps a single built-in atlas texture: a user-owned render
target you paint into yourself, then sample back as a first-class 'atlas'
primitive. It gives the UI a scratch surface for content the immediate-mode
primitives can't express directly — custom-shaded output, a cached composite,
externally rendered imagery — while still flowing through the normal draw
pipeline (clip stack, command batching, tinting).
Allocate it at init() with atlas, or any time via configure_atlas (a
single number makes a square atlas; pass { width, height } for a non-square
one). Paint into it with render_to_atlas, then blit the whole thing or a UV
sub-region into the frame:
awaitrenderer.init({atlas: {size: 1024,filter: 'linear'}})// or, later / to resize: renderer.configure_atlas({ width: 800, height: 600 })// paint into the atlas — the pixel→NDC mapping is rebased to the atlas size,// so (0,0)..(atlas_width, atlas_height) covers it exactly. Pass a clear colour// to wipe it first; omit it to composite over the existing contents.renderer.render_to_atlas(()=>{renderer.fill_round_rect(32,32,256,128,16,0xff3366ff)renderer.draw_text(48,64,'baked into the atlas',24,0xffffffff)},{r: 0,g: 0,b: 0,a: 0})// draw it into the current frame as the dedicated 'atlas' primitiverenderer.draw_atlas(x,y,w,h)// whole atlasrenderer.draw_atlas_region(x,y,w,h,0,0,0.5,0.5)// top-left quadrantatlas_texture_id() returns the texture id (usable anywhere a texture id is
accepted, e.g. draw_texture), and atlas_size() reports the current
dimensions (both are -1 / null until the atlas is configured).
ui_icons composes a set of vector icons from the renderer's own draw commands
(stroke_line, stroke_round_rect, fill_triangle, fill_circle, …) and bakes
them once into a single cached atlas texture — 512×512 by default, with each
icon occupying a 32×32 cell (so up to 16×16 = 256 icons share one texture).
Icons are baked white, so a draw call tints them to any colour for free:
consticons=newui_icons(renderer)// bake after renderer.init()icons.draw('folder',x,y)// 32px, untintedicons.draw('folder_open',x,y,16,theme_rgba(theme,'accent'))// 16px, tintedBuilt-in names include file, file_text, folder, folder_open,
chevron_right / chevron_down / chevron_up / chevron_left, plus,
minus, close, check, search, settings, trash, image, code,
star, circle, dot, and home (see ui_icon_name). Pass
{ atlas_size, cell_size } to the constructor to change the cache geometry, and
call bake() to refresh the atlas (e.g. after a device reset). The bake is built
on the general-purpose renderer.render_to_texture(target, w, h, draw), which
renders any UI draw commands into an offscreen texture.
Immediate mode rebuilds every primitive each frame. For content that rarely
changes — the body of an unfocused window, an inactive dock panel — that work
is wasted. The renderer can capture a slice of geometry between begin_layer()
and end_layer() into a ui_layer (its raw vertex bytes plus the draw commands
that reference them), then replay_layer() it on later frames — optionally
translated — without re-running the code that produced it:
// first frame: record while the panel draws normallyrenderer.begin_layer(x,y)render_panel_body(x,y,w,h)// text shaping, layout, …constlayer=renderer.end_layer()// stash this// later frames: skip the work, just replay the geometryrenderer.push_clip(x,y,w,h)renderer.replay_layer(layer,x-layer.origin_x,y-layer.origin_y)// move-awarerenderer.pop_clip()Commands are re-clipped against the live clip stack, so replaying inside a
push_clip confines the cached geometry. Invalidate (re-record) when the
content or the panel size changes. window_system uses this for inactive
windows out of the box; dock_system exposes the same behaviour behind its
cache_bodies option.
@webgpu/typesforGPU*typings.