Skip to content

Repository files navigation

@liamlangli/ui

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 through ui_renderer.
  • ui_icon — a set of vector icons (file, folder, folder_open, chevrons, search, settings, …) composed from ui_renderer draw 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 on dock/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 with load_theme, apply_theme, theme_color, theme_rgba, pack_color, and hex_to_normalized_rgba.
  • app_registry — installable apps described by a JSON manifest: install/uninstall, persistence, and update checks against each app's shipping_path. See dashboard.
  • plugins — opt-in, higher-level drop-in components (file_browser, graph, node_graph, im_dialog, code_editor, dashboard, box3d_demo, asset_hub cloud drive browser/uploader, material_audit, webtix WebGPU 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.

3D physics

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:physics

The 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.

Live preview

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 Pages

.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.

Workspace systems

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'

dock_system — docking workspace

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)

window_system — floating window workspace

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).

ui_task_queue — application-wide task messages

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.

Plugins

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.

file_browser — tree + project browser

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.

graph — node-graph canvas

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.

node_graph — dotted node editor with typed slots

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.

dashboard — full-screen app launcher

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.

im_dialog — IM chat panel

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).

code_editor — editable code surface

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.

material_audit — material / tiling inspector

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/uiui_pan_zoom); the graph and node_graph canvases run on the same module.

asset_hub — cloud drive browser + uploader (Google Drive)

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)

Google OAuth setup

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.com

To create the client id:

  1. In Google Cloud Console create (or pick) a project and open APIs & Services → Credentials → Create Credentials → OAuth client ID.
  2. Choose Application type: Web application.
  3. Add your site's origins to Authorized JavaScript origins — e.g. http://localhost:5173 for npm run dev and https://<user>.github.io for the deployed static site. (The token flow needs no redirect URI.)
  4. Enable the Google Drive API under APIs & Services → Library.
  5. Configure the OAuth consent screen (app name, support email), add .../auth/drive.readonly and .../auth/drive.file, and add your account as a test user for local development. drive.readonly is 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.

Cloud storage without the Asset Hub

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:

accessScopesSeesVerification
'app_files' (default)drive.filethe picked folder + files this app creatednone needed
'read_all'drive.readonly + drive.filethe whole drive, read-onlyrestricted 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.

Uploading assets

The Upload button (next to Change Folder, shown once a folder is open) accepts:

  • .glb — uploaded to the current folder as-is.
  • .zip containing 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's buffers/images URIs 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 .gltf is self-contained (embedded data: 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).

webtix — WebGPU path tracer

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.

Usage

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.

Stack layout (vstack / hstack / zstack)

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 rest

Chinese font loading

The 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.

Custom language font

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})

Custom Latin/monospace font

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,},})

Text view (selectable / copyable console)

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 frame

Copy 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).

CPU-updated textures

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)

Atlas render target

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 quadrant

atlas_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).

Icons

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, tinted

Built-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.

Retained layers (cached panels)

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.

Peer dependencies

About

webgpu imgui

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages