<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sai Chandan Kadarla</title>
    <description>The latest articles on DEV Community by Sai Chandan Kadarla (@chan27).</description>
    <link>https://dev.to/chan27</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3971708%2Fe8958e27-fce1-4541-946e-c7c1259c5de5.jpg</url>
      <title>DEV Community: Sai Chandan Kadarla</title>
      <link>https://dev.to/chan27</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chan27"/>
    <language>en</language>
    <item>
      <title>A code editor with no &lt;textarea&gt;: building the playground on canvas</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:03:40 +0000</pubDate>
      <link>https://dev.to/chan27/a-code-editor-with-no-building-the-playground-on-canvas-16l7</link>
      <guid>https://dev.to/chan27/a-code-editor-with-no-building-the-playground-on-canvas-16l7</guid>
      <description>&lt;p&gt;The obvious way to build an in-browser code editor is a &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; (or CodeMirror, or Monaco) on the left and your rendered output on the right. The &lt;a href="https://vel.kadarla.com/play" rel="noopener noreferrer"&gt;Vel playground&lt;/a&gt; doesn't do that. The editor on the left is &lt;em&gt;drawn&lt;/em&gt; — it's Vel widgets rendering text, a caret, and selection onto the same GPU canvas as everything else. There is no DOM text input you can see.&lt;/p&gt;

&lt;p&gt;That sounds like masochism. Here's why it's the right call, and what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why draw the editor
&lt;/h2&gt;

&lt;p&gt;The playground is a single WebGPU canvas. The moment you drop a &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; into it, you have two rendering systems fighting: the browser lays out and rasterizes the textarea its way (its own font metrics, its own subpixel rules, its own scrollbar), and Vel renders everything else its way. They don't match. They especially don't match when you zoom — the canvas content re-rasterizes crisply at the new &lt;a href="https://dev.to/blog/hidpi-crisp-text"&gt;device-pixel ratio&lt;/a&gt;, and the textarea does whatever the browser feels like. You get a seam right down the middle of your tool.&lt;/p&gt;

&lt;p&gt;Drawing the editor keeps it one scene. The editor (&lt;code&gt;CodeEditor.cpp&lt;/code&gt;) is a Vel widget like any other: it measures and paints syntax-highlighted text through the same FreeType atlas as the rest of the UI, so it's pixel-consistent and stays sharp at any zoom. It scrolls with the same machinery, themes with the same tokens, and lives inside a &lt;code&gt;Splitter&lt;/code&gt; next to the preview. One renderer, one look, one zoom behavior.&lt;/p&gt;

&lt;p&gt;It also keeps focus sane. With a real WebGPU canvas as the app surface, you want keyboard events going to the canvas, not getting eaten by a DOM input layered on top. The editor handles keys directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost: you own everything a textarea gave you for free
&lt;/h2&gt;

&lt;p&gt;This is the honest part. A &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; is decades of accumulated text-editing behavior you stop getting the instant you draw your own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Caret and selection.&lt;/strong&gt; Where's the cursor, in glyph terms? Click-to-place has to hit-test against measured glyph advances. Shift-arrow and click-drag selection, selection across wrapped lines, the highlight rectangles — all hand-rolled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Syntax highlighting.&lt;/strong&gt; The editor tokenizes &lt;code&gt;.vel&lt;/code&gt; and colors it as it draws. That's not a plugin; it's part of the paint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IME and clipboard.&lt;/strong&gt; This is where I &lt;em&gt;don't&lt;/em&gt; reinvent the wheel — I lean on the &lt;a href="https://dev.to/blog/accessible-canvas-ui"&gt;semantic layer&lt;/a&gt;. The editor projects a &lt;code&gt;TextArea&lt;/code&gt; semantic node, and the accessibility mirror mounts a real, invisible overlay input on it. So CJK composition, dictation, and the OS clipboard go through the browser's native machinery on a hidden element, while the canvas renders the visible caret and selection. Drawn surface, native input — you can have both, but only because that projection already existed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I'd had to build IME from scratch in C++, this would not have been worth it. The fact that the accessibility work &lt;em&gt;already&lt;/em&gt; solved native text input is what made a canvas editor affordable.&lt;/p&gt;

&lt;h2&gt;
  
  
  One WASM, two boot modes
&lt;/h2&gt;

&lt;p&gt;The neat structural trick: the playground and the live preview are the &lt;strong&gt;same WebAssembly binary&lt;/strong&gt;, booted two different ways. &lt;code&gt;playground_main.cpp&lt;/code&gt; checks a flag the host sets on &lt;code&gt;window&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;index.html&lt;/code&gt; → no flag → boots the full playground (editor + console + preview chrome).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.html&lt;/code&gt; → sets &lt;code&gt;__velAppMode = 1&lt;/code&gt; → boots as a bare app runner that renders a single &lt;code&gt;.vel&lt;/code&gt; full-screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I ship one &lt;code&gt;.wasm&lt;/code&gt; and get both the IDE and the app host out of it. No second build, no divergence.&lt;/p&gt;

&lt;p&gt;And the preview pane is the part I'm happiest with: it's not a canvas-in-a-canvas emulation. It's a &lt;strong&gt;real nested &lt;code&gt;&amp;lt;iframe src=app.html&amp;gt;&lt;/code&gt;&lt;/strong&gt; with its &lt;em&gt;own&lt;/em&gt; WebGPU device, mounted via an &lt;code&gt;HtmlView&lt;/code&gt; widget. The editor streams your source into it over &lt;code&gt;postMessage&lt;/code&gt;; the iframe compiles and renders it, and posts back &lt;code&gt;{velReady}&lt;/code&gt; on boot and &lt;code&gt;{velErrors}&lt;/code&gt; if compilation fails. That isolation is genuinely useful — your code runs in its own browser context, so a runaway loop or a crash in the previewed app can't take the playground down with it. (This is only possible because the &lt;a href="https://dev.to/blog/one-source-three-gpus-and-a-browser"&gt;web build needs no cross-origin-isolation headers&lt;/a&gt; — a &lt;code&gt;require-corp&lt;/code&gt; policy would have blocked the nested iframe.)&lt;/p&gt;

&lt;p&gt;The same &lt;code&gt;app.html#src=&amp;lt;base64&amp;gt;&lt;/code&gt; URL that the preview iframe uses is also what the Share button generates: a standalone, hosted-app link to whatever you wrote. The preview and the share target are the same code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing logs from inside the engine
&lt;/h2&gt;

&lt;p&gt;One smaller thing that took more thought than expected: the console tab shows live engine logs. But Vel logs through &lt;code&gt;spdlog&lt;/code&gt;, and &lt;code&gt;spdlog&lt;/code&gt; is a &lt;em&gt;private&lt;/em&gt; dependency of &lt;code&gt;libvel&lt;/code&gt; — it's not in the public headers, so the playground can't just attach a sink to it from outside.&lt;/p&gt;

&lt;p&gt;The fix was to put the capture &lt;em&gt;inside&lt;/em&gt; the library: &lt;code&gt;vel::log&lt;/code&gt; installs a custom &lt;code&gt;spdlog&lt;/code&gt; sink, and exposes a spdlog-free API (&lt;code&gt;recent()&lt;/code&gt; / &lt;code&gt;generation()&lt;/code&gt;) that the playground polls. So the engine keeps spdlog encapsulated, and the playground gets a live log feed without ever linking against spdlog itself. The console's other tab — Problems — is fed by the &lt;code&gt;{velErrors}&lt;/code&gt; messages coming back from the preview iframe. Two log streams, one panel.&lt;/p&gt;

&lt;p&gt;A 0.5s debounce on the editor's change events drives the recompile, so typing doesn't trigger a rebuild on every keystroke — it waits for you to pause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;For a general-purpose web app: no. If you need a text editor on a web page and you're not already rendering everything on a canvas, use Monaco and move on. Reimplementing selection semantics and clipboard behavior is a tax most projects shouldn't pay.&lt;/p&gt;

&lt;p&gt;For &lt;em&gt;this&lt;/em&gt; — a tool whose entire point is showing off a GPU UI engine, where the editor and the thing being edited should look like they belong to the same program — it's exactly right. The editor is the engine drawing itself. And the pieces that would've made it prohibitive (native IME, crisp HiDPI text, header-free iframe embedding) were already built for other reasons, so the editor mostly just &lt;em&gt;composed&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;That's the recurring shape of this whole project, honestly: the expensive capability you build for one reason turns out to be the thing that makes the next feature cheap.&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>editor</category>
      <category>ui</category>
      <category>cpp</category>
    </item>
    <item>
      <title>use \"header.h\": C++ interop as a language feature, not a binding layer</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:03:14 +0000</pubDate>
      <link>https://dev.to/chan27/use-headerh-c-interop-as-a-language-feature-not-a-binding-layer-5a4k</link>
      <guid>https://dev.to/chan27/use-headerh-c-interop-as-a-language-feature-not-a-binding-layer-5a4k</guid>
      <description>&lt;p&gt;Every "new UI language" I've tried treats talking to existing code as an afterthought. There's a foreign-function interface bolted on the side: you write an IDL, or a schema, or a &lt;code&gt;extern&lt;/code&gt; block, or you marshal everything through strings and JSON at the boundary. Interop is a &lt;em&gt;layer&lt;/em&gt; — a place where your nice new language stops and the ugly real world begins.&lt;/p&gt;

&lt;p&gt;I built Vel the other way around. The single constraint I set on day one was: &lt;strong&gt;you must be able to drop any existing C++ codebase into a &lt;code&gt;.vel&lt;/code&gt; file with one line, and call it like it was always there.&lt;/strong&gt; Not bindings. Not a boundary. A language feature.&lt;/p&gt;

&lt;p&gt;Here's what that actually takes in the compiler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lexer learned &lt;code&gt;::&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A normal DSL lexer doesn't care about &lt;code&gt;::&lt;/code&gt;. Vel's does, because the whole interop story hinges on qualified C++ names being first-class tokens, not strings I parse later. So &lt;code&gt;::&lt;/code&gt; is a real token type (&lt;code&gt;velc/Lexer.cpp&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;tokens_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;push_back&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;TokenType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ColonColon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"::"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;startLoc&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the parser, when it's reading a name, greedily consumes &lt;code&gt;::&lt;/code&gt; segments to build a qualified identifier (&lt;code&gt;velc/Parser.cpp&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TokenType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ColonColon&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s"&gt;"::"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tiny loop is what makes &lt;code&gt;std::vector&lt;/code&gt;, &lt;code&gt;myapp::db::fetchAll&lt;/code&gt;, and &lt;code&gt;myorg::ui::Theme&lt;/code&gt; parse as single names rather than syntax errors. The grammar didn't need a special "FFI call" construct — qualified names just &lt;em&gt;are&lt;/em&gt; names, everywhere a name is legal: in expressions, in type annotations, in event handlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;use&lt;/code&gt; is an include, not an import system
&lt;/h2&gt;

&lt;p&gt;There are exactly two &lt;code&gt;use&lt;/code&gt; forms, and the lexer tags both with one keyword (&lt;code&gt;use&lt;/code&gt; or &lt;code&gt;Use&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;use "backend/users.h"     // raw C++ — emitted verbatim as #include
use "widgets/stat.vel"     // cross-file vel import — pulls in components
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The C++ form does the least surprising thing possible: it becomes a &lt;code&gt;#include "backend/users.h"&lt;/code&gt; at the top of the generated translation unit. No parsing of the header, no wrapping, no shadow types. velc doesn't try to &lt;em&gt;understand&lt;/em&gt; your C++ — it just makes sure it's in scope, and then trusts that the names you use resolve when the C++ compiler runs.&lt;/p&gt;

&lt;p&gt;That trust is the design. velc is not a C++ frontend and never tries to be. It type-checks the &lt;em&gt;Vel&lt;/em&gt; parts — widget props, signal types, the registry — and for anything in a &lt;code&gt;use&lt;/code&gt;d header, it emits the call and lets the downstream C++ compiler be the type checker. If you typo &lt;code&gt;myapp::fetchUzers()&lt;/code&gt;, you don't get a velc error; you get a normal C++ "no member named" error pointing at the right place. The DSL borrows the host language's type system instead of duplicating a worse copy of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  State can be any C++ type, including async
&lt;/h2&gt;

&lt;p&gt;Because qualified names work in type annotations, your reactive state isn't limited to primitives. This is a real line of Vel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#UserList
  @users: std::vector&amp;lt;myapp::User&amp;gt; = await myapp::fetchUsers()
  @filter = ""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are happening that only work because interop is in the type system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;std::vector&amp;lt;myapp::User&amp;gt;&lt;/code&gt; is a type annotation with a template argument and a qualified name — the parser handles all of it because, again, names are names.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;myapp::fetchUsers()&lt;/code&gt; is a qualified call in an initializer expression.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;await&lt;/code&gt; on a call whose result type is a C++ type makes velc emit an &lt;code&gt;AsyncSignal&amp;lt;std::vector&amp;lt;myapp::User&amp;gt;&amp;gt;&lt;/code&gt;, kicked off with &lt;code&gt;std::async&lt;/code&gt; and polled each tick — exposing &lt;code&gt;.loading&lt;/code&gt; / &lt;code&gt;.ready&lt;/code&gt; / &lt;code&gt;.value&lt;/code&gt; / &lt;code&gt;.error&lt;/code&gt; to the DSL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;velc emits, roughly, a &lt;code&gt;Signal&lt;/code&gt;/&lt;code&gt;AsyncSignal&lt;/code&gt; member of &lt;em&gt;your&lt;/em&gt; C++ type, initialized by &lt;em&gt;your&lt;/em&gt; C++ call. The framework has no idea what &lt;code&gt;myapp::User&lt;/code&gt; is. It doesn't need to. Your existing code links into the binary as an ordinary translation unit — same compiler, same flags, same linker — and the generated component holds your types directly. There's no serialization at the boundary because there is no boundary.&lt;/p&gt;

&lt;p&gt;The same mechanism is why a registry component call compiles to a plain constructor — codegen emits &lt;code&gt;vel::gen::Stat{...}&lt;/code&gt; the same way it emits &lt;code&gt;myapp::fetchUsers()&lt;/code&gt;. To velc, framework calls and your calls are the same kind of thing: qualified names it resolves to C++ and hands off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs — and it's a real cost
&lt;/h2&gt;

&lt;p&gt;This is the section that separates the design from a sales pitch. Making C++ a language feature means inheriting C++'s consequences with none of the guardrails a binding layer would have given you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There is no safety boundary.&lt;/strong&gt; A binding layer is also a &lt;em&gt;firewall&lt;/em&gt; — it validates, it sandboxes, it catches type mismatches at the edge. Vel has none of that on purpose. If your &lt;code&gt;use&lt;/code&gt;d function dereferences a null pointer, your UI segfaults, exactly like C++ does. The DSL is not memory-safe across the interop line because there is no line. You're writing C++ with a nicer syntax for the view layer, and you own C++'s footguns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors surface one layer down.&lt;/strong&gt; A mistake in a qualified call isn't a friendly DSL diagnostic; it's a C++ compiler error in generated code. I keep the generated &lt;code&gt;.vel.cpp&lt;/code&gt; readable for exactly this reason, but the failure mode is still "read a template error," not "Vel told you nicely."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No hot-swap of the native side.&lt;/strong&gt; Vel hot-reloads &lt;code&gt;.vel&lt;/code&gt; files by recompiling and swapping the generated component. But your &lt;code&gt;use&lt;/code&gt;d C++ is compiled into the binary — change &lt;code&gt;users.h&lt;/code&gt; and you're doing a real rebuild, not a sub-second reload. The interop is static, which is what makes it zero-overhead and also what makes it not live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It assumes one toolchain.&lt;/strong&gt; Because the call sites are literal C++, your backend has to build with the same C++ compiler and ABI as &lt;code&gt;libvel&lt;/code&gt;. That's fine for a C++ shop; it's not a polyglot story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I think it's the right trade for what Vel is for: native apps where your data layer is already C++ (or C, or anything with a C++-callable surface), and you want a dense, reactive view language on top without an integration tax. The interop being unsafe is the same reason it's frictionless — the compiler gets out of the way completely.&lt;/p&gt;

&lt;p&gt;Every other UI language I've used would make me write a binding for &lt;code&gt;fetchUsers&lt;/code&gt;. Vel makes me write &lt;code&gt;await myapp::fetchUsers()&lt;/code&gt;. That difference — interop as a keyword instead of interop as a subproject — is the reason the thing exists at all.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>dsl</category>
      <category>compilers</category>
      <category>ffi</category>
    </item>
    <item>
      <title>Making a canvas-rendered UI a screen reader can actually use</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:02:48 +0000</pubDate>
      <link>https://dev.to/chan27/making-a-canvas-rendered-ui-a-screen-reader-can-actually-use-b83</link>
      <guid>https://dev.to/chan27/making-a-canvas-rendered-ui-a-screen-reader-can-actually-use-b83</guid>
      <description>&lt;p&gt;Here's the uncomfortable thing about rendering your UI to a GPU canvas: to a screen reader, you've drawn nothing. There's one &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; element and a blob of pixels. A blind user tabbing through your app hears silence. This is the wall every canvas UI hits — Flutter's web target, Figma, anything game-engine-shaped — and most of them either ship a half-hearted shadow DOM or quietly don't solve it.&lt;/p&gt;

&lt;p&gt;Vel renders everything on the GPU through &lt;a href="https://dev.to/blog/lume-rendering-engine"&gt;Lume&lt;/a&gt;, so I had the same problem. The way out was to stop treating the pixels as the UI, and treat them as &lt;em&gt;one projection&lt;/em&gt; of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One tree, two backends
&lt;/h2&gt;

&lt;p&gt;A Vel widget already knows how to describe itself for layout and paint. The insight was that it can describe itself a second way — not "here's how I look" but "here's what I &lt;em&gt;am&lt;/em&gt;": a role, a label, a value, some state. Pixels for the eye; semantics for everything else.&lt;/p&gt;

&lt;p&gt;So every widget fills out a &lt;code&gt;SemNode&lt;/code&gt; in a &lt;code&gt;describe()&lt;/code&gt; method, and a single collector walks the tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SemRole&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Checkbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Radio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Switch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Slider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TextArea&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// editable — single &amp;amp; multi-line&lt;/span&gt;
    &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProgressBar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Alert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ListItem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;SemNode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;SemRole&lt;/span&gt;     &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SemRole&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// accessible name (button text, field label…)&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// current value (field text, slider readout…)&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt;        &lt;span class="n"&gt;focusable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// participates in Tab order&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt;        &lt;span class="n"&gt;editable&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// mount a real &amp;lt;input&amp;gt; for IME/keyboard&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt;        &lt;span class="n"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt;        &lt;span class="n"&gt;hasRange&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;valueMin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valueMax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valueNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// slider/progress&lt;/span&gt;
    &lt;span class="n"&gt;Rect&lt;/span&gt;        &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// logical-pixel bounds == CSS px on the web&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt;         &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// stable for one frame&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SemNode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;collectSemantics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Widget&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The roles are deliberately small and intent-based — they map 1:1 to ARIA/DOM roles on the web and to platform accessibility roles elsewhere. A &lt;code&gt;Btn&lt;/code&gt; reports &lt;code&gt;Button&lt;/code&gt; with its text as the label. A &lt;code&gt;Slider&lt;/code&gt; reports &lt;code&gt;Slider&lt;/code&gt; with &lt;code&gt;hasRange&lt;/code&gt; and the live &lt;code&gt;valueNow&lt;/code&gt;. Decorative containers report &lt;code&gt;None&lt;/code&gt; and get dropped from the output entirely, so the semantic tree is &lt;em&gt;flatter and cleaner&lt;/em&gt; than the visual tree — assistive tech doesn't care about your spacer boxes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;collectSemantics&lt;/code&gt; walks the widget tree in paint order and produces a flat list, stamping each node with its post-layout rect and a stable id. That flat list is the whole accessibility model. Everything downstream is a consumer of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DOM mirror
&lt;/h2&gt;

&lt;p&gt;On the web, a small JS layer takes that flat list and maintains a &lt;strong&gt;hidden DOM tree&lt;/strong&gt; positioned exactly over the canvas — a &lt;code&gt;&amp;lt;div role="button"&amp;gt;&lt;/code&gt; at the button's rect, an &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; at each editable field, an &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; for headings. It's visually invisible (the pixels are the real UI) but it's &lt;em&gt;structurally real&lt;/em&gt;: screen readers read it, &lt;code&gt;Tab&lt;/code&gt; moves through the &lt;code&gt;focusable&lt;/code&gt; nodes in order, and focus rings track the actual element.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;editable&lt;/code&gt; flag is the one that earns its keep. When a node is editable, the mirror mounts a real, transparent &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; over it. That single decision hands you the entire native text stack for free: IME composition for CJK, dictation, autocorrect, the OS clipboard, mobile virtual keyboards. I am not reimplementing input method editors in C++ — I'm letting the browser do what it's extremely good at, on an element the user can't see, while the canvas renders the caret and selection.&lt;/p&gt;

&lt;p&gt;This is also why the playground's code editor is canvas-drawn rather than a &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;: keeping the visual surface on the canvas lets it render crisply at any zoom and stay one coherent scene, while the hidden mirror still gives it &lt;code&gt;TextArea&lt;/code&gt; semantics and real keyboard/IME. You get the rendering control of a canvas and the accessibility of a DOM input, instead of choosing one.&lt;/p&gt;

&lt;p&gt;A few smaller things that matter and are easy to forget: the framework honors &lt;code&gt;prefers-reduced-motion&lt;/code&gt; (the eased transitions collapse to instant), and focus is a first-class widget state, not a paint afterthought — so a keyboard user always sees where they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same projection is machine-readable UI
&lt;/h2&gt;

&lt;p&gt;Here's the part I didn't expect when I started. The semantic tree serializes to compact JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;semanticsToJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SemNode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That JSON is exposed to the page (and to the WASM host) as &lt;code&gt;vel_dump_semantics&lt;/code&gt;. It was built for the DOM mirror — but it turns out to be exactly what an &lt;strong&gt;AI agent&lt;/strong&gt; needs to operate the UI: read the interface as a small structured document, find the node labeled "Save" by role and label, act on it by id. No screenshot, no pixel-OCR, no vision model guessing at button boundaries. The accessibility tree and the agent-automation tree are &lt;em&gt;the same tree&lt;/em&gt;, because both are asking the same question — "what is actually here and what can I do with it?" — that pixels can't answer.&lt;/p&gt;

&lt;p&gt;It also means the initial HTML is crawlable and the UI degrades to something legible with no JS, which I get without any extra work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The honest tradeoffs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Widgets have to opt in.&lt;/strong&gt; A widget that forgets to &lt;code&gt;describe()&lt;/code&gt; itself is invisible to the mirror exactly the way the raw canvas is. Accessibility isn't automatic — it's a second method every widget owes, and the discipline to fill it in is on the framework author. The registry widgets do; a careless custom widget might not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a projection, so it can drift.&lt;/strong&gt; The semantic rect is the widget's logical bounds, recomputed each frame. If a widget lies about what it is, a screen reader believes the lie. There's no pixel-level verification that the mirror matches what's drawn — they're consistent only because they come from the same node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coordinate-anchored overlays are fiddly.&lt;/strong&gt; Positioning hidden DOM exactly over GPU-drawn rects, across DPR changes and scrolling, is the kind of thing that's correct until a transform sneaks in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the result is the one that mattered: a GPU-rendered UI where the screen reader announces real buttons, &lt;code&gt;Tab&lt;/code&gt; lands where you'd expect, CJK input works, and an agent can read the whole interface as JSON — all from one extra description per widget. The canvas accessibility wall isn't a law of physics. It's just what happens when pixels are the &lt;em&gt;only&lt;/em&gt; thing you project.&lt;/p&gt;

&lt;p&gt;Next on this front: richer live-region announcements (so toasts and async results get spoken), and per-platform native accessibility backends — the same &lt;code&gt;SemNode&lt;/code&gt; tree, but feeding macOS's &lt;code&gt;NSAccessibility&lt;/code&gt; and Windows UIA directly instead of only the web DOM.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>webassembly</category>
      <category>ui</category>
      <category>cpp</category>
    </item>
    <item>
      <title>The frame was fast. The relayout was 65 too slow.</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:02:22 +0000</pubDate>
      <link>https://dev.to/chan27/the-frame-was-fast-the-relayout-was-65x-too-slow-a0g</link>
      <guid>https://dev.to/chan27/the-frame-was-fast-the-relayout-was-65x-too-slow-a0g</guid>
      <description>&lt;p&gt;I ported &lt;a href="https://github.com/krausest/js-framework-benchmark" rel="noopener noreferrer"&gt;js-framework-benchmark&lt;/a&gt;'s protocol onto Vel's core — no window, no GPU, no vsync, pure CPU — because I wanted to know if "compiles to native C++" actually translated into the layout numbers I was assuming it did. Each row is a color swatch, a flexible text label, and a button. I created 10,000 of them and measured.&lt;/p&gt;

&lt;p&gt;The first number was great. The second number was embarrassing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the benchmark found
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| rows  | build (tree) | layout (cold) | relayout (warm) |
|------:|-------------:|--------------:|----------------:|
|   100 |      0.03 ms |      0.04 ms  |       0.04 ms   |
|  1000 |      0.26 ms |      0.30 ms  |     →19.6 ms←   |
| 10000 |      1.99 ms |      2.22 ms  |     → 206 ms←   |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Build&lt;/strong&gt; — constructing 10,000 widgets — costs 2ms. This is where the "native C++" thesis pays off bluntly: C++ allocation crushes JavaScript's &lt;code&gt;createElement&lt;/code&gt;. Nothing to do here; it was already faster than the framework I was benchmarking against.&lt;/p&gt;

&lt;p&gt;Now look at the last column. &lt;strong&gt;Relayout (warm)&lt;/strong&gt; is the cost of laying out a tree that &lt;em&gt;did not change&lt;/em&gt; — same widgets, same text, same constraints, the steady state you're in every frame while the user just moves the mouse. It cost &lt;strong&gt;206ms for 10k rows&lt;/strong&gt;. That's not a 60fps frame; that's three frames dropped to lay out a list that didn't move.&lt;/p&gt;

&lt;p&gt;And the tell is in the comparison: warm relayout cost &lt;em&gt;the same&lt;/em&gt; as the cold pass. Re-laying-out an unchanged tree was doing the full amount of work as laying it out for the first time. That's the signature of zero memoization — every frame, from scratch, as if it had never seen this tree before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost wasn't geometry — it was text
&lt;/h2&gt;

&lt;p&gt;My assumption was that "layout is slow" meant the flexbox math was slow: constraint propagation, two passes, intrinsic sizing. I was wrong, and a profiler said so immediately. The 206ms was almost entirely in one place:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Text::measure&lt;/code&gt; → &lt;code&gt;FreeTypeRasterizer::measureText&lt;/code&gt;, which walks each string codepoint by codepoint and asks FreeType for the advance width of every glyph via &lt;code&gt;FT_Load_Glyph&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Per frame. For every label. Whether or not the text had changed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FT_Load_Glyph&lt;/code&gt; is not free — it's loading and scaling a glyph outline to get its metrics. Doing it for every character of every visible string, 60 times a second, on text that is identical to last frame, is pure waste. The geometry math was a rounding error next to it. I'd been optimizing the wrong mental model of where layout time goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two caches, both valid forever
&lt;/h2&gt;

&lt;p&gt;The fix is in &lt;code&gt;engine/src/text/FreeTypeRasterizer.cpp&lt;/code&gt; — two process-lifetime caches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-glyph advance cache&lt;/strong&gt;, keyed on &lt;code&gt;(face, pixelSize, codepoint)&lt;/code&gt;. A glyph's advance width never changes for a given face and size, so the first time you measure an 'e' at 32px you load it; every 'e' after that is a hashmap hit. This makes even &lt;em&gt;cold&lt;/em&gt; layout of varied text fast, because common characters are shared across every string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-string width cache&lt;/strong&gt;, keyed on &lt;code&gt;(face, pixelSize, string)&lt;/code&gt;. Relayout of unchanged text becomes a single lookup — measure the label once, and every subsequent frame that the text is identical is O(1). It's bounded to 200k entries so that live typing (which generates a new string every keystroke) can't grow it without limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key insight that makes this safe: &lt;strong&gt;these values are immutable.&lt;/strong&gt; A glyph advance for a fixed face and pixel size is a constant of the universe; it will never be different. So there's no invalidation logic, no staleness, no cache-coherence problem — the hard part of caching simply doesn't exist here. You compute it once and trust it forever.&lt;/p&gt;

&lt;p&gt;The result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;th&gt;relayout before&lt;/th&gt;
&lt;th&gt;relayout after&lt;/th&gt;
&lt;th&gt;speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;19.6 ms&lt;/td&gt;
&lt;td&gt;0.30 ms&lt;/td&gt;
&lt;td&gt;~65×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;206 ms&lt;/td&gt;
&lt;td&gt;2.2 ms&lt;/td&gt;
&lt;td&gt;~93×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 10,000-row list now relays out in ~2ms — comfortably inside a 60fps frame. That's the budget Figma-class apps live in, and it was the difference between "compiles to native, therefore fast" being a slogan and being true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: don't lay out at all
&lt;/h2&gt;

&lt;p&gt;Caching makes a frame cheap. The bigger win is not running the frame. Vel is damage-tracked: an atomic &lt;code&gt;frameDirty&lt;/code&gt; flag, raised by any &lt;code&gt;Widget::markDirty()&lt;/code&gt;, gates whether the next frame does anything. When nothing's changed, the app sits in &lt;code&gt;glfwWaitEventsTimeout&lt;/code&gt; and uses ~0 CPU — no layout, no paint, no spin. Animating widgets re-arm the flag from their &lt;code&gt;tick()&lt;/code&gt;; a static page just sleeps.&lt;/p&gt;

&lt;p&gt;So the steady state is: idle costs nothing, and when something &lt;em&gt;does&lt;/em&gt; change, the relayout it triggers is ~2ms instead of 206ms. Both halves matter. A fast frame you run 60 times a second on an idle app is still a battery fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The string cache trades memory for time, bounded crudely.&lt;/strong&gt; 200k entries is a fixed ceiling, not an LRU — it's a backstop against live-typing churn, not a tuned eviction policy. For pathological workloads (millions of unique strings) you'd want real eviction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's still a full tree walk.&lt;/strong&gt; Relayout re-visits every node; it's just that each visit is now cheap. The honest next step is dirty-subtree layout — skipping subtrees whose constraints and content are unchanged — so a 100k-row tree doesn't pay even the cheap per-node cost. Today the whole tree is re-walked; it's fast enough that I haven't needed to, which is its own kind of answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It assumes advances are independent.&lt;/strong&gt; The per-string cache works because today a string's width is the sum of its glyphs' advances. HarfBuzz shaping will break that assumption (kerning, ligatures, complex scripts) — the cache key still holds (same string, same width) but the per-glyph cache stops being sufficient on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The meta-lesson is the one I keep relearning: &lt;strong&gt;measure before you optimize, because your intuition about where the time goes is usually wrong.&lt;/strong&gt; I'd have happily spent a week making the flex math faster and moved the 206ms to 204ms. The profiler pointed at text measurement in thirty seconds, and a cache with no invalidation logic — the easy kind — bought two orders of magnitude.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>cpp</category>
      <category>ui</category>
      <category>graphics</category>
    </item>
    <item>
      <title>Why your HiDPI text is blurry, and the physical-pixel fix</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:01:56 +0000</pubDate>
      <link>https://dev.to/chan27/why-your-hidpi-text-is-blurry-and-the-physical-pixel-fix-58l5</link>
      <guid>https://dev.to/chan27/why-your-hidpi-text-is-blurry-and-the-physical-pixel-fix-58l5</guid>
      <description>&lt;p&gt;Build a canvas renderer, run it on a Retina display, and the first thing you notice is that your text looks slightly &lt;em&gt;soft&lt;/em&gt;. Not broken — just a touch blurry, the way a screenshot scaled up 2× is blurry. Everyone has seen it; fewer people know exactly why, and the why points straight at the fix.&lt;/p&gt;

&lt;p&gt;When I &lt;a href="https://dev.to/blog/lume-rendering-engine"&gt;ripped Skia out of Vel&lt;/a&gt; and started rendering text through FreeType myself, this was the first wall. Here's what's actually going on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug is a resolution mismatch
&lt;/h2&gt;

&lt;p&gt;A Retina display has a &lt;code&gt;devicePixelRatio&lt;/code&gt; of 2 (sometimes 3). Your window is, say, 800 logical points wide but &lt;strong&gt;1600 physical pixels&lt;/strong&gt; wide. The GPU draws at physical resolution; your layout reasons in logical points.&lt;/p&gt;

&lt;p&gt;Now think about a glyph. The naive path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You want 16pt text, so you rasterize the glyph into a 16-pixel-tall bitmap.&lt;/li&gt;
&lt;li&gt;You draw that bitmap into a layout that's measured in logical points.&lt;/li&gt;
&lt;li&gt;The canvas matrix scales everything by the DPR (2×) to fill the physical framebuffer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is the killer. That 16px glyph bitmap gets stretched to 32 physical pixels by the GPU's bilinear sampler. You rasterized at half the resolution the screen can show, then blew it up. The font hinting, the carefully anti-aliased edges — all smeared across pixels that don't line up. &lt;em&gt;That's&lt;/em&gt; the softness. It's not the font; it's that you rendered it for a display half as sharp as the one you're on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: rasterize at physical pixels
&lt;/h2&gt;

&lt;p&gt;The rule is one sentence: &lt;strong&gt;rasterize glyphs at the size they'll occupy in physical pixels, and draw them 1:1.&lt;/strong&gt; For 16pt text on a 2× display, you ask FreeType for a 32-pixel glyph and blit it without scaling. The edges land on real device pixels. It's sharp because nothing resampled it.&lt;/p&gt;

&lt;p&gt;Concretely, that's why Vel's rasterizer takes a &lt;em&gt;pixel size&lt;/em&gt;, not a point size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt;  &lt;span class="nf"&gt;setPixelSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uintptr_t&lt;/span&gt; &lt;span class="n"&gt;face&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;pixelSize&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;GlyphBitmap&lt;/span&gt; &lt;span class="nf"&gt;rasterize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uintptr_t&lt;/span&gt; &lt;span class="n"&gt;face&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;codepoint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uintptr_t&lt;/span&gt; &lt;span class="n"&gt;face&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;pixelSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;codepoint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The renderer computes &lt;code&gt;pixelSize = logicalSize × devicePixelRatio&lt;/code&gt; and rasterizes there. The glyph bitmap is an R8 coverage map (one byte of alpha per pixel) sized for the device, and it's drawn at physical resolution with no scale in the matrix. The layout still happens in logical points — measurement uses the subpixel-aware &lt;code&gt;advance&lt;/code&gt;, so wrapping and alignment are DPR-independent — but the &lt;em&gt;pixels&lt;/em&gt; are always device-native.&lt;/p&gt;

&lt;h2&gt;
  
  
  The atlas has to be keyed on DPR
&lt;/h2&gt;

&lt;p&gt;Here's the part that bites you if you bolt DPI on later. A glyph atlas is a texture you pack rasterized glyphs into so you upload once and draw many. The obvious key is &lt;code&gt;(face, codepoint)&lt;/code&gt;. That's wrong the moment a second DPR shows up.&lt;/p&gt;

&lt;p&gt;The 'A' at 32px (16pt on a 2× display) and the 'A' at 48px (16pt on a 3× display) are &lt;em&gt;different bitmaps&lt;/em&gt;. If the atlas key ignores pixel size, you'll serve a cached 2× glyph to a 3× context and you're blurry again — now intermittently, which is worse than always.&lt;/p&gt;

&lt;p&gt;So Vel keys everything on pixel size: faces are cached per &lt;code&gt;(path, pixelSize)&lt;/code&gt;, advances and glyphs per &lt;code&gt;(face, pixelSize, codepoint)&lt;/code&gt;. Each DPR effectively gets its own crisp set of glyphs in the atlas. Move a window from a Retina laptop to an external 1× monitor and the renderer rasterizes a fresh set at the new pixel size; the old ones stay cached in case you drag it back. On the web, the host watches &lt;code&gt;matchMedia&lt;/code&gt; for DPR changes (zooming the browser changes &lt;code&gt;devicePixelRatio&lt;/code&gt;) and re-rasterizes, which is why Vel re-renders sharply when you Ctrl-+ instead of pixel-doubling like an image.&lt;/p&gt;

&lt;p&gt;There's a subtlety in the metrics, too. FreeType hands you a face's vertical metrics, and you need a consistent convention for the baseline or text drifts vertically between fonts. Vel follows Skia's convention directly — ascent negative (above the baseline), descent positive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;FaceMetrics&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;ascent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// negative (Skia convention: above baseline)&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;descent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// positive&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;leading&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// line gap&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// ascender - descender + leading&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Matching Skia's sign convention wasn't aesthetic — it meant text that used to be laid out by Skia kept landing on the same baseline after I swapped the rasterizer, so nothing shifted by a pixel when the engine changed underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The honest tradeoffs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory scales with DPR diversity.&lt;/strong&gt; An app dragged across a 1×, a 2×, and a 3× display can hold three rasterizations of the same glyphs. For a UI font that's kilobytes; it's a real cost only with huge glyph ranges (CJK) across many sizes, which is when you'd add atlas eviction. Today the advance caches are bounded but the glyph atlas leans on the fact that UI text uses a small set of sizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You must thread DPR everywhere measurement happens.&lt;/strong&gt; Logical for layout, physical for raster — get the boundary wrong in one place and you get half-size text or a 2× atlas miss. The discipline is the cost of the sharpness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No fractional-DPR cleverness yet.&lt;/strong&gt; A 1.5× display rasterizes at 1.5× and that's fine; I'm not doing anything special for fractional scales beyond honoring them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is the thing you actually want and rarely think about: text that's pin-sharp on a MacBook, on an external monitor, and in a browser at 150% zoom — the same FreeType path on all three, because "physical pixels" is a rule the whole renderer obeys rather than a per-platform patch.&lt;/p&gt;

&lt;p&gt;The next text milestone is the harder one: HarfBuzz shaping for kerning and complex scripts (Arabic, Devanagari), where a "string" stops being a sequence of independent glyph advances and measurement gets genuinely expensive — which is exactly the case the &lt;a href="https://dev.to/blog/65x-faster-relayout"&gt;layout cache&lt;/a&gt; was built to protect.&lt;/p&gt;

</description>
      <category>graphics</category>
      <category>fonts</category>
      <category>hidpi</category>
      <category>cpp</category>
    </item>
    <item>
      <title>One source, three GPUs, and a browser: putting a native UI on WebGPU</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:00:10 +0000</pubDate>
      <link>https://dev.to/chan27/one-source-three-gpus-and-a-browser-putting-a-native-ui-on-webgpu-43cf</link>
      <guid>https://dev.to/chan27/one-source-three-gpus-and-a-browser-putting-a-native-ui-on-webgpu-43cf</guid>
      <description>&lt;p&gt;The Vel playground at &lt;a href="https://vel.kadarla.com/play" rel="noopener noreferrer"&gt;vel.kadarla.com/play&lt;/a&gt; is the same engine that draws the native macOS app, compiled to WebAssembly and pointed at the browser's GPU. Not a re-implementation, not a canvas2d fallback, not a screenshot service — the literal C++ widget tree, running in your tab, rendering through WebGPU.&lt;/p&gt;

&lt;p&gt;The surprising part isn't that it works. It's how little code the browser target needed, and one deployment property that makes it genuinely cheap to host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dawn is the portability layer, so the browser is just another backend
&lt;/h2&gt;

&lt;p&gt;I wrote about &lt;a href="https://dev.to/blog/windows-one-surface-seam"&gt;the platform seam&lt;/a&gt; being two functions. The web is the cleanest demonstration of why that design pays off. Native platforms hand the GPU a window handle; the browser binds the GPU to an HTML canvas by CSS selector. So &lt;code&gt;SurfaceWeb.cpp&lt;/code&gt; barely does anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Web (Emscripten) surface glue. There is no window handle — the surface is&lt;/span&gt;
&lt;span class="c1"&gt;// bound to the "#canvas" element in Surface.cpp. So we only return a non-null&lt;/span&gt;
&lt;span class="c1"&gt;// sentinel so the validity check passes; resize is driven by the JS host.&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;attachNativeSurface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GLFWwindow&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;reinterpret_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// sentinel: "canvas-backed"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;resizeNativeSurface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason this is enough: I build Lume on &lt;strong&gt;Dawn&lt;/strong&gt;, Google's WebGPU implementation. Natively, Dawn translates my &lt;code&gt;wgpu::&lt;/code&gt; calls to Metal, D3D12, or Vulkan. On the web, Emscripten ships &lt;strong&gt;emdawnwebgpu&lt;/strong&gt; — a port of the exact same &lt;code&gt;webgpu.h&lt;/code&gt; API that forwards to the browser's &lt;em&gt;real&lt;/em&gt; WebGPU device. So the engine code doesn't change. The WGSL shaders don't change. The instanced-rect pipeline that draws every shape doesn't change. They all compile to WASM and talk to a GPU that happens to live behind the browser instead of behind the kernel.&lt;/p&gt;

&lt;p&gt;There's no &lt;code&gt;#ifdef __EMSCRIPTEN__&lt;/code&gt; in the paint code. The web is a backend, not a rewrite — the same way Windows was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blocking loop problem, and the header you don't need
&lt;/h2&gt;

&lt;p&gt;A native app loop is allowed to block. Vel's idle path literally parks the thread in &lt;code&gt;glfwWaitEventsTimeout&lt;/code&gt; and burns ~0 CPU until an event arrives. You cannot do that on the web: blocking the main thread freezes the tab.&lt;/p&gt;

&lt;p&gt;The usual answer is threads — run your loop on a Web Worker, use &lt;code&gt;SharedArrayBuffer&lt;/code&gt; to talk to the main thread. But &lt;code&gt;SharedArrayBuffer&lt;/code&gt; is the expensive choice, and not for the reason people expect. Since Spectre, browsers only expose it when the page is &lt;strong&gt;cross-origin isolated&lt;/strong&gt;, which means you must serve these two headers on every response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those headers are quietly hostile. &lt;code&gt;require-corp&lt;/code&gt; means every cross-origin resource the page loads — fonts, images, analytics, an embedded iframe — must opt in with its own CORP/COEP headers or it's blocked. It breaks third-party embeds. It means you can't just drop the build on a static CDN and link it. And it makes the playground hard to embed in &lt;em&gt;another&lt;/em&gt; page (like the docs).&lt;/p&gt;

&lt;p&gt;So I went the other way: &lt;strong&gt;single-threaded, with ASYNCIFY.&lt;/strong&gt; ASYNCIFY is an Emscripten transform that rewrites the WASM so a "blocking" call can actually unwind the stack, yield to the browser's event loop, and resume later. The engine keeps its natural blocking-loop shape in C++; ASYNCIFY makes that cooperate with the event loop instead of freezing it. No worker, no &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, and therefore &lt;strong&gt;no COOP/COEP headers at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The payoff is operational: the playground is four static files (&lt;code&gt;index.html&lt;/code&gt;, &lt;code&gt;index.js&lt;/code&gt;, a ~3 MB &lt;code&gt;index.wasm&lt;/code&gt;, and an &lt;code&gt;app.html&lt;/code&gt;). It hosts on plain Vercel with no special headers, and it embeds in the docs as an ordinary &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;. The preview pane you see is a &lt;em&gt;real&lt;/em&gt; nested iframe running its own WebGPU device, with source streamed in over &lt;code&gt;postMessage&lt;/code&gt; — which is only possible because nothing requires cross-origin isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  HiDPI falls out for free
&lt;/h2&gt;

&lt;p&gt;One detail I like: the canvas backing size is set to &lt;code&gt;CSS size × devicePixelRatio&lt;/code&gt; by the JS host, and Surface reconfigures the wgpu surface to match. That's the same physical-pixel rule the &lt;a href="https://dev.to/blog/hidpi-crisp-text"&gt;native text rasterizer uses&lt;/a&gt; — so text on the web is snapped to device pixels and stays crisp on Retina, using the identical code path as the desktop app. Cross-platform consistency isn't a goal I chase; it's a consequence of there being one renderer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;ASYNCIFY isn't free. It instruments the binary, which adds size and a small per-call overhead on the functions that can unwind — you don't want it everywhere, so you scope which calls it applies to. Single-threaded also means exactly that: no offloading layout or decode to a worker, so a genuinely heavy frame has nowhere to hide. For a UI that idles at ~0 CPU and lays out &lt;a href="https://dev.to/blog/65x-faster-relayout"&gt;10k rows in ~2 ms&lt;/a&gt; that's fine; for a compute-heavy app it would be a real ceiling.&lt;/p&gt;

&lt;p&gt;And the honest caveat: this is WebGPU, so it needs a recent browser. Chrome and Edge have had it on by default since 113; Safari shipped it; Firefox is partial. A blank canvas almost always means "this browser doesn't have WebGPU enabled," which is a worse failure mode than a 2D fallback would be — I chose fidelity over reach.&lt;/p&gt;

&lt;p&gt;But the thing I set out to prove held up: porting a native GPU UI to the browser was a 20-line surface file and a build-flag decision, not a parallel web codebase. The hard part of "write once, run everywhere" was never the rendering. It was refusing to let the platforms leak into the parts that aren't platform-specific.&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>webgpu</category>
      <category>cpp</category>
      <category>emscripten</category>
    </item>
    <item>
      <title>The Windows port that was one 20-line file (and the DLL that wasn't there)</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:00:09 +0000</pubDate>
      <link>https://dev.to/chan27/the-windows-port-that-was-one-20-line-file-and-the-dll-that-wasnt-there-47bm</link>
      <guid>https://dev.to/chan27/the-windows-port-that-was-one-20-line-file-and-the-dll-that-wasnt-there-47bm</guid>
      <description>&lt;p&gt;"Cross-platform" usually means a codebase pockmarked with &lt;code&gt;#ifdef _WIN32&lt;/code&gt;. You open a file expecting layout logic and instead find three forks of everything: window creation, the GL context, the swapchain, DPI handling. The platform differences leak into every layer because nobody drew a line that says &lt;em&gt;the OS-specific part stops here.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When I brought Vel to Windows, I wanted to find out how small that line could be. The answer turned out to be two functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seam is two functions
&lt;/h2&gt;

&lt;p&gt;Vel renders through &lt;strong&gt;Lume&lt;/strong&gt;, my GPU engine built on Dawn (Google's WebGPU implementation). Dawn already gives me one drawing API that lands on Metal, D3D12, or Vulkan depending on the platform. So the only thing that's genuinely OS-specific is the very first handshake: &lt;em&gt;take a window, hand back something the GPU can present into.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the whole platform contract — &lt;code&gt;engine/src/platform/Platform.h&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="n"&gt;vel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

&lt;span class="c1"&gt;// Returns an opaque native handle for the window's render surface.&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;attachNativeSurface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GLFWwindow&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Resize the native backing store, if the platform needs one.&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt;  &lt;span class="n"&gt;resizeNativeSurface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;nativeHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;widthPx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;heightPx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything above this line — the widget tree, layout, paint, the entire engine — is platform-neutral and compiles identically everywhere. &lt;code&gt;engine/src/gpu/Surface.cpp&lt;/code&gt; calls &lt;code&gt;attachNativeSurface&lt;/code&gt;, gets back a &lt;code&gt;void*&lt;/code&gt;, and feeds it into the matching &lt;code&gt;wgpu::SurfaceSource*&lt;/code&gt; descriptor. It never learns what OS it's on.&lt;/p&gt;

&lt;p&gt;On macOS, that handle is a &lt;code&gt;CAMetalLayer&lt;/code&gt; you attach to the &lt;code&gt;NSView&lt;/code&gt;. It's the longest of the implementations because Cocoa makes you set up a layer, pick a pixel format, and track the backing scale factor — about 40 lines.&lt;/p&gt;

&lt;p&gt;Windows is shorter. The whole file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define GLFW_EXPOSE_NATIVE_WIN32
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;GLFW/glfw3.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;GLFW/glfw3native.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="n"&gt;vel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

&lt;span class="c1"&gt;// On Windows the wgpu surface binds directly to the window's HWND, so there is&lt;/span&gt;
&lt;span class="c1"&gt;// no intermediate layer to create — we just hand back the GLFW window's HWND.&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;attachNativeSurface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GLFWwindow&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;glfwGetWin32Window&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// No-op: there is no CAMetalLayer-equivalent backing store to resize. The&lt;/span&gt;
&lt;span class="c1"&gt;// swapchain is sized by wgpu::Surface::Configure() in Surface.cpp.&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;resizeNativeSurface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. There's no layer to construct, so &lt;code&gt;attachNativeSurface&lt;/code&gt; is one line, and resize is a no-op because the D3D12 swapchain binds straight to the &lt;code&gt;HWND&lt;/code&gt; — when the window resizes, &lt;code&gt;wgpu::Surface::Configure()&lt;/code&gt; handles it. The asymmetry with macOS isn't sloppiness; it's the platforms being honestly different, contained to the one place where they differ.&lt;/p&gt;

&lt;p&gt;CMake selects the right file and nothing else changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cmake"&gt;&lt;code&gt;&lt;span class="nb"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;APPLE&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;VEL_PLATFORM_SOURCES engine/src/platform/SurfaceMac.mm&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;elseif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;WIN32&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;VEL_PLATFORM_SOURCES engine/src/platform/SurfaceWin.cpp&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;endif&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The grep that convinced me the design held: there is not a single &lt;code&gt;#ifdef _WIN32&lt;/code&gt; anywhere in the framework or registry. The OS forks live in two ~20-40 line files, and the compiler picks one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that doesn't show up at build time
&lt;/h2&gt;

&lt;p&gt;So the port "worked" almost immediately — it compiled, it linked, the window opened. And then it crashed the first time anything tried to draw text, with a missing-DLL error for a library that was unmistakably present in my vcpkg tree.&lt;/p&gt;

&lt;p&gt;This is the Dawn-on-D3D12 trap, and it's a good one. Dawn's D3D12 backend compiles shaders at runtime using DirectX's standalone compiler — &lt;code&gt;dxcompiler.dll&lt;/code&gt; and &lt;code&gt;dxil.dll&lt;/code&gt;. It doesn't link them. It &lt;code&gt;LoadLibrary()&lt;/code&gt;s them lazily, the first time it needs to compile a shader.&lt;/p&gt;

&lt;p&gt;That timing is the whole problem. vcpkg's "applocal" deploy step — the thing that copies your DLL dependencies next to your &lt;code&gt;.exe&lt;/code&gt; — works by inspecting the binary's &lt;strong&gt;link-time&lt;/strong&gt; import table. But these DLLs were never imported at link time; they get pulled in at &lt;em&gt;runtime&lt;/em&gt; by name. So the tooling that's supposed to make Windows binaries portable looks at the executable, sees no dependency on &lt;code&gt;dxcompiler.dll&lt;/code&gt;, and faithfully copies nothing.&lt;/p&gt;

&lt;p&gt;The fix is to stop relying on inference and just copy them, for any Vel app target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cmake"&gt;&lt;code&gt;&lt;span class="nb"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;vel_copy_dxc_runtime TARGET&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;NOT WIN32&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nb"&gt;return&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nb"&gt;endif&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;_dxc_bin &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VCPKG_INSTALLED_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VCPKG_TARGET_TRIPLET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/bin"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;foreach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;_dll dxcompiler.dll dxil.dll&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nb"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;EXISTS &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;_dxc_bin&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;_dll&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nb"&gt;add_custom_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;TARGET &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; POST_BUILD
                COMMAND &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CMAKE_COMMAND&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; -E copy_if_different
                    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;_dxc_bin&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;_dll&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"$&amp;lt;TARGET_FILE_DIR:&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;"&lt;/span&gt;
                VERBATIM&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nb"&gt;endif&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nb"&gt;endforeach&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nb"&gt;endfunction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson generalizes past Windows: &lt;strong&gt;any dependency resolved by &lt;code&gt;dlopen&lt;/code&gt;/&lt;code&gt;LoadLibrary&lt;/code&gt; is invisible to your build graph.&lt;/strong&gt; Static analysis of the binary can't find it, so your packaging step won't either. If a library loads plugins, codecs, or — like Dawn — a shader compiler by name at runtime, you own deploying those files yourself. It will pass every test on the dev machine where the DLL happens to be on &lt;code&gt;PATH&lt;/code&gt;, and fail on the first clean box.&lt;/p&gt;

&lt;p&gt;One more Windows-specific wrinkle worth naming: MSVC exports no symbols from a shared library unless you annotate them, and I wasn't about to sprinkle &lt;code&gt;__declspec(dllexport)&lt;/code&gt; through public headers that also compile on macOS. CMake's &lt;code&gt;WINDOWS_EXPORT_ALL_SYMBOLS ON&lt;/code&gt; generates the export table for the whole &lt;code&gt;libvel&lt;/code&gt;, so the showcase and hot-reload plugins link against it the same way they do everywhere else. One property, not a header rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost, and what it didn't
&lt;/h2&gt;

&lt;p&gt;What the seam buys: adding a platform is a bounded, legible task. I can point at the two functions a new OS has to implement and know that's the entire surface area. Linux is the same shape — an &lt;code&gt;X11&lt;/code&gt;/Wayland &lt;code&gt;SurfaceX11.cpp&lt;/code&gt; against the same contract — which is why it's a known quantity rather than a rewrite.&lt;/p&gt;

&lt;p&gt;What it costs: the abstraction is only as portable as Dawn is, and I've inherited Dawn's operational reality — including a shader compiler that loads itself at runtime and a build system that can't see it coming. I traded a pile of &lt;code&gt;#ifdef&lt;/code&gt;s for a dependency I don't fully control. For a 2D UI engine that's a good trade; if I needed exotic per-backend GPU features, the seam would start leaking and I'd be writing the &lt;code&gt;#ifdef&lt;/code&gt;s after all.&lt;/p&gt;

&lt;p&gt;But the honest result is the one I wanted: Windows support is a 20-line file and a DLL-copy function, not a fork of the codebase. The interesting work stayed in the engine, where it belongs.&lt;/p&gt;

&lt;p&gt;Next up is the Linux surface — same contract, X11 first — and then a per-app DLL-deploy helper so the runtime-loaded libraries travel with shipped apps automatically instead of living in the SDK's &lt;code&gt;bin&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>windows</category>
      <category>webgpu</category>
      <category>graphics</category>
    </item>
    <item>
      <title>Building Vel: A Token-Efficient, Compile-to-Native UI Language</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Sun, 07 Jun 2026 19:42:05 +0000</pubDate>
      <link>https://dev.to/chan27/building-vel-a-token-efficient-compile-to-native-ui-language-275f</link>
      <guid>https://dev.to/chan27/building-vel-a-token-efficient-compile-to-native-ui-language-275f</guid>
      <description>&lt;p&gt;I've been building &lt;strong&gt;Vel&lt;/strong&gt; — a new declarative UI language plus the runtime and component registry to back it. The goal is unapologetically ambitious: write production desktop apps with the expressive density of Tailwind classes, the reactivity model of SolidJS, and the rendering quality of Flutter, while letting you call into any existing C/C++ backend without a binding layer.&lt;/p&gt;

&lt;p&gt;This post walks through the architecture I landed on, why each tier exists, and what the language actually buys you in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-tier rule
&lt;/h2&gt;

&lt;p&gt;Most UI frameworks blur three concerns: the language, the runtime substrate, and the opinionated widget set. Vel splits them on purpose, with a strict one-way dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;language  →  framework  →  registry  →  application
(velc)       (libvel)       (vel::ui::*)    (.vel + main.cpp)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Language&lt;/strong&gt;: a small, indentation-sensitive DSL (&lt;code&gt;.vel&lt;/code&gt;) and a single-pass compiler (&lt;code&gt;velc&lt;/code&gt;). It parses to an AST, type-checks against a primitive registry, and emits idiomatic modern C++ that consumes the framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework (&lt;code&gt;libvel&lt;/code&gt;)&lt;/strong&gt;: the substrate. Widget pipeline (measure / place / paint / tick), Skia-backed Painter, an event dispatcher, the reactive primitives (&lt;code&gt;Signal&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;Computed&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;AsyncSignal&amp;lt;T&amp;gt;&lt;/code&gt;), and infra (router, storage, net shim, IO, time, validate, notify, JSON, typed context). No application policy — no themes, no shortcuts, no business rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registry (&lt;code&gt;vel::ui::*&lt;/code&gt;)&lt;/strong&gt;: 43 baseline widgets in the shadcn idiom — Btn, Card, Tabs, Accordion, Dialog, Sheet, Table, TreeView, Stepper, Image, and so on. Built by composing framework primitives. Third parties can ship their own registries as plain &lt;code&gt;.vel&lt;/code&gt; files; the language has cross-file imports as a first-class feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application&lt;/strong&gt;: your &lt;code&gt;.vel&lt;/code&gt; files and a thin &lt;code&gt;main.cpp&lt;/code&gt; shell. Themes, keyboard shortcuts, and routing decisions live here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code in a higher tier never reaches into a lower one. The framework never imports an icon set. The runtime never hardcodes a key combo. This isn't religion; it's the only way the language stays composable as third-party registries appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a DSL — and why compile to C++?
&lt;/h2&gt;

&lt;p&gt;Two reasons.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;source-token density&lt;/strong&gt;. The same UI in JSX vs. Vel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bg0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontWeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;700&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Hello&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V p=lg g=md bg=bg0 r=md
  T "Hello" font=bold
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's roughly a 4–6× compression on real components without losing the declarative shape. For LLMs writing UI, that's the difference between fitting a feature in context and not. Vel was designed from day one with agent-authored code as a primary user.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;no VM at runtime&lt;/strong&gt;. Vel compiles directly to typed C++ that calls into a static &lt;code&gt;libvel&lt;/code&gt;. There's no interpreter loop, no JIT, no garbage collector. Layout, paint, and event dispatch are all monomorphic virtual calls on stable widget instances. On idle, the app sits in &lt;code&gt;glfwWaitEventsTimeout&lt;/code&gt; and uses ~0 CPU — a global frame-dirty flag short-circuits the entire pipeline until something changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reactive substrate
&lt;/h2&gt;

&lt;p&gt;The keystone primitive is &lt;code&gt;Signal&amp;lt;T&amp;gt;&lt;/code&gt; — a value plus a listener list. Everything reactive flows from it.&lt;/p&gt;

&lt;p&gt;In the DSL, &lt;code&gt;@state&lt;/code&gt; declarations become typed &lt;code&gt;Signal&amp;lt;T&amp;gt;&lt;/code&gt; members on the generated component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#Counter
  @count = 0
  @doubled = $count * 2          // detected as derived → Computed&amp;lt;int&amp;gt;
  @users: std::vector&amp;lt;User&amp;gt; = await fetchUsers()  // → AsyncSignal&amp;lt;...&amp;gt;

  V g=md
    T "Count: {$count}, doubled: {$doubled}" font=bold
    Btn "++" -&amp;gt; click =&amp;gt; $count = $count + 1
    if $users.loading
      Spinner
    else
      for u in $users.value
        T "{u.name}"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What velc emits, semantically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Signal&amp;lt;int&amp;gt; count_{0}&lt;/code&gt; — plain reactive cell.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Computed&amp;lt;int&amp;gt; doubled_{ [this]{ return count_.get() * 2; } }&lt;/code&gt; — the codegen walks the init expression's AST, finds &lt;code&gt;$count&lt;/code&gt; as a dependency, and binds the computed to it. Any &lt;code&gt;count_.set(...)&lt;/code&gt; propagates: count → computed → component → &lt;code&gt;needsRebuild_ = true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AsyncSignal&amp;lt;std::vector&amp;lt;User&amp;gt;&amp;gt; users_&lt;/code&gt; — kicked off with &lt;code&gt;std::async(std::launch::async, ...)&lt;/code&gt;, polled each tick. Exposes &lt;code&gt;.loading&lt;/code&gt; / &lt;code&gt;.ready&lt;/code&gt; / &lt;code&gt;.value&lt;/code&gt; / &lt;code&gt;.error&lt;/code&gt; as method calls in the DSL.&lt;/li&gt;
&lt;li&gt;The component constructor &lt;code&gt;.listen()&lt;/code&gt;s every signal it owns. The next &lt;code&gt;tick()&lt;/code&gt; calls &lt;code&gt;rebuild()&lt;/code&gt;, re-measures, and re-places before the frame paints. Tree rebuilds are coarse-grained — like React's &lt;code&gt;setState&lt;/code&gt;, not Solid's fine-grained reactivity — but the granularity is set per &lt;code&gt;#Component&lt;/code&gt;, so it composes cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Effects (&lt;code&gt;~ $a $b =&amp;gt; body&lt;/code&gt;) compile to per-dep listeners — declarative side effects without polluting event handlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  First-class C/C++ interop
&lt;/h2&gt;

&lt;p&gt;The single most important constraint I set: &lt;strong&gt;you must be able to drop any existing C++ codebase into Vel with one line.&lt;/strong&gt; No bindings, no schemas, no IDL. The language gives you two primitives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;use "myorg/db.h"        // raw C++ include — verbatim
use "ui/card.vel"        // cross-file vel import — pulls in components
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once a header is in scope, qualified names work in every expression. The lexer learned &lt;code&gt;::&lt;/code&gt; so &lt;code&gt;db::fetchUsers()&lt;/code&gt;, &lt;code&gt;std::vector&amp;lt;myorg::User&amp;gt;&lt;/code&gt;, and &lt;code&gt;await myapp::loadAsync()&lt;/code&gt; all parse and codegen straight through to the underlying calls. The framework knows nothing about your backend — velc just emits the include and the call site. Your existing C++ links into the binary like any other translation unit.&lt;/p&gt;

&lt;p&gt;This is the reason Vel exists at all. Every other "new UI language" I've tried makes interop a second-class citizen. Here, the interop is part of the type system from day one — state can be of any C++ type, expressions can call any user namespace, and the DSL's type annotations support templates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture of the rendering pipeline
&lt;/h2&gt;

&lt;p&gt;The render path is Skia all the way down, but Vel adds a few layout patterns worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Constraints model&lt;/strong&gt;: identical to Flutter (min/max W/H), one measure pass returns intrinsic size, one place pass writes final rects. A small but important fix in Vel — when the cross axis is unbounded (e.g. inside a scrollable), &lt;code&gt;align=Stretch&lt;/code&gt; degrades to &lt;code&gt;Start&lt;/code&gt; so children don't get infinite size cascades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frame-level damage tracking&lt;/strong&gt;: an atomic &lt;code&gt;frameDirty&lt;/code&gt; flag, raised by any &lt;code&gt;Widget::markDirty()&lt;/code&gt; call, controls whether the next frame runs at all. Animating widgets re-arm the flag from their &lt;code&gt;tick()&lt;/code&gt;; static pages don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portal layer for overlays&lt;/strong&gt;: tooltips, popovers, dialogs, sheets, menus, and toasts go through an &lt;code&gt;OverlayHost&lt;/code&gt; that owns a per-frame portal queue. Widgets nested inside scrolled containers register their panel rect plus the current scroll offset, so the panel paints in screen space regardless of where in the tree it was declared. This solved the classic "popover gets clipped by a Card with &lt;code&gt;clipContent&lt;/code&gt;" problem cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DPR awareness&lt;/strong&gt;: the GL context is created at framebuffer resolution, the widget tree lays out in logical points, and the canvas matrix is scaled on each paint. Resize is handled by re-creating the Skia surface against the new framebuffer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The registry as a primitive, not a library
&lt;/h2&gt;

&lt;p&gt;The 43-widget baseline ships in &lt;code&gt;vel/ui/&lt;/code&gt;, but the registry mechanic isn't reserved for the framework. Any &lt;code&gt;.vel&lt;/code&gt; file is a registry artifact. Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// widgets/stat.vel
#Stat label:str value:str hint:str=""
  Card elev=1
    V g=xs
      T $label fg=fgMuted font=uiSm
      T $value font=bold/display
      if $hint != ""
        T $hint fg=fgFaint font=uiSm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// showcase.vel
use "widgets/stat.vel"

#Showcase
  H g=md
    Stat label="MAU" value="42,103" hint="last 30 days"
    Stat label="Latency" value="84ms" hint="p99"
    Stat label="Errors" value="0.02%" hint="↓ 18%"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;velc resolves the import, parses the imported file to extract the component's param signature, and at the call site emits &lt;code&gt;std::make_unique&amp;lt;vel::gen::Stat&amp;gt;("MAU", "42,103", "last 30 days")&lt;/code&gt; — positional constructor args, type-checked statically by the C++ compiler downstream. Third parties can ship registries as bare &lt;code&gt;.vel&lt;/code&gt; files via git, vendoring, or eventually a package manifest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's working today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Compiler with lexer, parser (forward-progress-guarded), type checker, codegen, and &lt;code&gt;--watch&lt;/code&gt; mode.&lt;/li&gt;
&lt;li&gt;Reactive substrate: &lt;code&gt;Signal&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;Computed&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;AsyncSignal&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;PersistentSignal&amp;lt;T&amp;gt;&lt;/code&gt; (auto-syncs to disk), effects, typed &lt;code&gt;ctx&amp;lt;T&amp;gt;(key)&lt;/code&gt; Provider substrate.&lt;/li&gt;
&lt;li&gt;Framework primitives: &lt;code&gt;Router&lt;/code&gt; (path matching, history), &lt;code&gt;storage&lt;/code&gt; (JSON-backed KV), &lt;code&gt;net&lt;/code&gt; (pluggable HTTP shim), &lt;code&gt;io&lt;/code&gt; (file ops + watcher), &lt;code&gt;time&lt;/code&gt; (debounce / throttle / interval / timeout), &lt;code&gt;validate&lt;/code&gt; (form rules), &lt;code&gt;notify&lt;/code&gt; (toast bus), &lt;code&gt;json&lt;/code&gt; (wraps nlohmann), per-widget cursor states, theme switching with full tree rebuild.&lt;/li&gt;
&lt;li&gt;Registry: 43 widgets including Table (virtualized, sortable, multi-select), TreeView, Stepper, Image (Skia-decoded with LRU cache), and the full shadcn-style overlay set.&lt;/li&gt;
&lt;li&gt;DX: agent-readable &lt;code&gt;widgets.json&lt;/code&gt; regenerated on every build; cross-file imports; &lt;code&gt;--watch&lt;/code&gt; for hot iteration.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The big open items are hot reload (file watch is in; &lt;code&gt;dlopen&lt;/code&gt; swap of the generated &lt;code&gt;.so&lt;/code&gt; is not), a real &lt;code&gt;vel.json&lt;/code&gt; package manifest, per-widget damage rectangles (right now damage is frame-level), and a few more registry primitives — Video, SVG, devtools overlay, a syntax-highlighted code editor.&lt;/p&gt;

&lt;p&gt;If the three-tier story holds — and so far it does — Vel ends up being the smallest source surface area you can use to ship a production native desktop app, with the deepest backend interop story of any framework I'm aware of.&lt;/p&gt;

&lt;p&gt;I'll keep posting as the registry grows.&lt;/p&gt;

</description>
      <category>uilang</category>
      <category>dsl</category>
      <category>cpp</category>
      <category>skia</category>
    </item>
    <item>
      <title>From Skia to Lume: writing my own 2D rendering engine for Vel</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Sun, 07 Jun 2026 19:21:02 +0000</pubDate>
      <link>https://dev.to/chan27/from-skia-to-lume-writing-my-own-2d-rendering-engine-for-vel-1h37</link>
      <guid>https://dev.to/chan27/from-skia-to-lume-writing-my-own-2d-rendering-engine-for-vel-1h37</guid>
      <description>&lt;p&gt;Vel is about a week old. I started it as a DSL plus framework experiment, and from day one the rendering substrate was Skia. That wasn't an accident. Skia is the most complete 2D rendering API you can drop into a C++ project today. Clean canvas surface, built-in text with system font fallback, image decode, a GPU backend that already works on every desktop OS. If you want a UI framework drawing pixels by the end of the week, Skia is what you reach for.&lt;/p&gt;

&lt;p&gt;But the plan was always to replace it. Skia is a brilliant CPU-rasterization library bolted to a GPU backend, and as soon as you push it hard, the bolts show. Flutter publicly battled this same class of problems for years before they shipped &lt;a href="https://docs.flutter.dev/perf/impeller" rel="noopener noreferrer"&gt;Impeller&lt;/a&gt; and finally got rid of the runtime shader-compilation jank that made early Flutter apps stutter. I'd rather not repeat their story. So once the DSL was working and the framework was responding to my changes the way I wanted, I started writing the renderer I actually needed.&lt;/p&gt;

&lt;p&gt;The new engine is called &lt;strong&gt;Lume&lt;/strong&gt;. It lives in &lt;a href="https://github.com/chan27-2/Vel/tree/main/engine" rel="noopener noreferrer"&gt;&lt;code&gt;engine/&lt;/code&gt;&lt;/a&gt; of the Vel repo. This post is about why I started with Skia, why I'm replacing it now, and what Lume does differently.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fft1tyln4pkbe5ot91hnq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fft1tyln4pkbe5ot91hnq.png" alt="Vel's showcase app, every pixel painted by Lume." width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Skia first
&lt;/h2&gt;

&lt;p&gt;Skia gave Vel three things I needed in the first few days of having a framework at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A clean &lt;code&gt;SkCanvas&lt;/code&gt; API the widget pipeline could draw into without me writing any GPU code.&lt;/li&gt;
&lt;li&gt;A working text renderer (CoreText on macOS, FreeType elsewhere, all behind the same API) with system font fallback.&lt;/li&gt;
&lt;li&gt;Image decode plus GPU upload as table-stakes, so Image widgets just worked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That let me focus on the actual hard problem of the framework, the DSL and the reactive substrate. Layout, signals, hot-reload, event dispatch, the widget registry. The rendering substrate didn't need to be mine yet. Skia was a load-bearing dependency for exactly the amount of time it took the rest of the system to stop being the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm replacing it now
&lt;/h2&gt;

&lt;p&gt;Once the DSL and framework were in shape, I had a clear view of what the renderer was actually doing for me, and what it was going to cost as the surface area grew. Three things, all well-known to anyone who's tried to ship a Skia-based UI runtime at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Shader compilation jank.&lt;/strong&gt; Skia compiles shaders the first time it sees a new primitive &lt;em&gt;during the frame that wants to draw it&lt;/em&gt;. The first time you open a dialog with a blur, you pay 40 to 120 ms while Skia builds the right shader for the GPU. Flutter spent years trying to predict and pre-warm these (the infamous "skp shader cache") and never fully won. The Impeller team's own postmortem describes this as the engine's defining flaw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Tessellation on the CPU.&lt;/strong&gt; Skia turns rounded rectangles, strokes, and curves into triangle meshes on the CPU, then ships them to the GPU. For one card it's free. For a table of 200 rows with rounded corners and hover highlights, the CPU is doing a lot of work that a fragment shader could do once and for all with &lt;a href="https://iquilezles.org/articles/distfunctions2d/" rel="noopener noreferrer"&gt;an analytic SDF&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The framework didn't own its render path.&lt;/strong&gt; This was the real one. Every cross-cutting question I expected to hit later (popovers clipping inside scroll views, text positioning in tight cells, atlas eviction policy, draw order across overlays, HiDPI handling) was eventually going to bottom out in Skia's behavior, and the answer was always going to be "work around it." When you don't control the rendering substrate, every one of those concerns is something you negotiate with a library that doesn't know what your widgets are.&lt;/p&gt;

&lt;p&gt;I'm not the first person to land here. Flutter, Servo, Bevy's UI work, Slint: every team building a rendering-heavy UI runtime has eventually concluded that owning the engine is the only way to make the rest of the system answer to one design. The cheaper time to do it is before you have a year of code depending on someone else's render path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I borrowed from Flutter
&lt;/h2&gt;

&lt;p&gt;Impeller's defining decision is &lt;em&gt;ahead-of-time shader compilation&lt;/em&gt;. Every shader the engine could ever need is compiled at build time into Metal or Vulkan IR and bundled with the binary. The "first render is slow" problem goes away because there is no first render. Every shader has already been seen.&lt;/p&gt;

&lt;p&gt;That insight was the foundation. The other thing I borrowed: keep the pipeline list small. Impeller has on the order of a dozen pipelines, not hundreds. The way you do that is by reducing every primitive you draw to a small set of canonical shapes (rounded rects with optional ring strokes, textured quads, line segments) and varying their behavior through &lt;em&gt;uniforms&lt;/em&gt;, not new shaders.&lt;/p&gt;

&lt;p&gt;Lume's pipeline count today is four:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shape&lt;/strong&gt;: analytic SDF rounded-rect. Fills, strokes, circles, lines, soft shadows all collapse to this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line&lt;/strong&gt;: per-segment rotated quad with butt caps for polylines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text&lt;/strong&gt;: textured quad sampling an R8 glyph atlas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image&lt;/strong&gt;: textured quad sampling RGBA8 with corner-radius mask.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every shape in the Vel showcase is one of those four primitives. A &lt;code&gt;roundedFill&lt;/code&gt; is the shape pipeline with &lt;code&gt;strokeWidth=0, radius=R&lt;/code&gt;. A &lt;code&gt;shadowRect&lt;/code&gt; is the same pipeline with &lt;code&gt;blur&amp;gt;0&lt;/code&gt;, which switches the fragment shader to a &lt;code&gt;smoothstep&lt;/code&gt; falloff instead of the AA clamp. A &lt;code&gt;circleStroke&lt;/code&gt; is a shape with &lt;code&gt;radius=w/2&lt;/code&gt;. The instance attributes do the heavy lifting; the GPU just rasterizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Lume actually is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fci36wcawc7jpvz7hfsgj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fci36wcawc7jpvz7hfsgj.jpg" alt="Lume, a 2D rendering engine for Vel." width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The architecture is four layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L1  platform/   → CAMetalLayer attach (macOS). Future: ANativeWindow, HWND, canvas.
L2  gpu::Device → Dawn instance + adapter + device + queue (singleton).
L2  gpu::Surface → wgpu::Surface bound to the window's native layer.
L3  paint/      → DawnPainterImpl: four WGSL pipelines, glyph atlas,
                  per-instance state for shape/line/text/image, submission-
                  order draw segments.
L4  Painter API → public surface: fill, roundedFill, stroke, polyline,
                  arc, image, text, pushClip, pushTransform, and so on.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole stack is &lt;code&gt;engine/include/vel/&lt;/code&gt; (public headers) plus &lt;code&gt;engine/src/&lt;/code&gt; (about 3,000 lines of implementation). The framework calls into the Painter API and never sees a WebGPU type.&lt;/p&gt;

&lt;p&gt;Three details that took real effort:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The glyph atlas is keyed on physical pixel size.&lt;/strong&gt; When you ask for 14 px text on a 2× DPR display, FreeType rasterizes at 28 px. Lume's atlas cache key includes that physical size, so a window dragged to a 1× external monitor doesn't render upsampled-blurry text. It just rasterizes a second 14 px entry and uses that. The dst rect stays in logical pixels; the GPU samples the physical atlas 1:1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Submission-order draw segments.&lt;/strong&gt; Originally Lume batched all-shapes, then all-lines, then all-text per frame. This broke the Table widget's sticky header: the header background was drawn before the row text, so row text overdrew the header bg, and rows became visible &lt;em&gt;through&lt;/em&gt; the header during scroll. The fix was to track a small &lt;code&gt;DrawCmd&lt;/code&gt; list (&lt;code&gt;{kind, firstInstance, count}&lt;/code&gt;) in submission order and emit one Draw call per segment. Same-kind cmds fuse. The Table works, and any widget that depends on draw order ("this card needs to be on top of those cards") works for the same reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drag capture survives reactive rebuilds.&lt;/strong&gt; Vel is signal-driven. When the user drags a Slider, the slider writes to a signal, which triggers a re-render, which replaces the Slider widget instance. The new instance has &lt;code&gt;dragging_=false&lt;/code&gt;. The drag dies after one mouse-move event. The fix wasn't in Lume; it was in the framework's &lt;code&gt;EventDispatcher&lt;/code&gt;. &lt;code&gt;captureDrag(handler)&lt;/code&gt; registers a callable that closes over the slider's geometry plus its &lt;code&gt;onChange&lt;/code&gt; (whose own closure captures the long-lived owning component's &lt;code&gt;this&lt;/code&gt;). Mouse-move and mouse-up route to the captured handler directly, bypassing the widget tree. Drag continues across any number of rebuilds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4pyh3vgzsfer6hmgdnao.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4pyh3vgzsfer6hmgdnao.png" alt="Cards, forms, and overlays from the showcase, all going through Lume's four pipelines." width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Skia / Impeller / Lume comparison
&lt;/h2&gt;

&lt;p&gt;The dimensions that matter for a 2D UI runtime:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Skia (Vel v1)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Impeller (Flutter)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Lume (Vel today)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shader compilation&lt;/td&gt;
&lt;td&gt;JIT, at first-draw time&lt;/td&gt;
&lt;td&gt;AOT, build-time&lt;/td&gt;
&lt;td&gt;WGSL precompiled by Dawn at device init&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shape rendering&lt;/td&gt;
&lt;td&gt;CPU tessellation → GPU triangles&lt;/td&gt;
&lt;td&gt;Compute + tessellation hybrid&lt;/td&gt;
&lt;td&gt;Analytic SDF in the fragment shader&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipeline count&lt;/td&gt;
&lt;td&gt;hundreds (one per primitive + state combo)&lt;/td&gt;
&lt;td&gt;~12&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text&lt;/td&gt;
&lt;td&gt;CoreText / FreeType per platform&lt;/td&gt;
&lt;td&gt;Manual rasterizer → MTLTexture atlas&lt;/td&gt;
&lt;td&gt;FreeType → R8 atlas, OS/2 typo metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle frame cost&lt;/td&gt;
&lt;td&gt;Always paints&lt;/td&gt;
&lt;td&gt;Always paints&lt;/td&gt;
&lt;td&gt;~0 (frame-dirty flag short-circuits the whole pipeline)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HiDPI&lt;/td&gt;
&lt;td&gt;Surface scaled in canvas&lt;/td&gt;
&lt;td&gt;Per-pass DPR awareness&lt;/td&gt;
&lt;td&gt;Atlas keyed on physical px; dst rect in logical px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-platform reach&lt;/td&gt;
&lt;td&gt;GL/Vulkan/Metal/D3D11&lt;/td&gt;
&lt;td&gt;Metal + Vulkan (+ work-in-progress)&lt;/td&gt;
&lt;td&gt;Dawn handles Metal/Vulkan/D3D12/WebGPU from one WGSL source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Library code in libvel&lt;/td&gt;
&lt;td&gt;Skia + image codecs (~25 MB linked)&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;libvel.dylib&lt;/code&gt; size (macOS arm64)&lt;/td&gt;
&lt;td&gt;~30 MB&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hot-reload safety&lt;/td&gt;
&lt;td&gt;Crashes if plugin link drops Skia symbols&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;Plugin links the same &lt;code&gt;libvel.dylib&lt;/code&gt;; nothing else to share&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single most useful number on that table is the bottom one. With Skia gone, the hot-reload plugin no longer needs to think about which graphics symbols it shares with the host. &lt;code&gt;libvel.dylib&lt;/code&gt; is the sole boundary. A hot reload re-emits a &lt;code&gt;.vel.cpp&lt;/code&gt;, recompiles 200 lines, and &lt;code&gt;dlopen&lt;/code&gt;s the new dylib in under a second.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2relz9oyy5auyx4mjqiu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2relz9oyy5auyx4mjqiu.png" alt="A virtualized Table with a sticky header, the test case that forced submission-order draw segments into existence." width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Lume doesn't do yet (the honest section)
&lt;/h2&gt;

&lt;p&gt;This is the first usable version of the engine, and I'd be lying if I said it was at parity with Skia for every workload. Three real gaps:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compute-shader Gaussian blur.&lt;/strong&gt; Lume's shadow is currently a &lt;code&gt;smoothstep&lt;/code&gt; outer falloff applied to the rounded-rect SDF. For small blur radii (4 to 16 px, which covers most UI shadows) it's perceptually identical to a Gaussian. For larger radii it reads as "the rect got bigger and softer at the edges" rather than a true Gaussian. A two-pass separable Gaussian in a compute pipeline is next; for now the cheap approximation is honest about what it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Complex-script text shaping.&lt;/strong&gt; I link HarfBuzz; I don't drive it yet. Latin, Cyrillic, and Greek render correctly. Arabic ligatures, Devanagari conjuncts, vertical text: those are next. The FreeType path is in; the HarfBuzz shaping pass on top of it isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The platform surface is macOS-only.&lt;/strong&gt; Dawn supports Vulkan and D3D12, so the underlying portability is real. The part missing is the window-to-surface glue. Lume has a &lt;code&gt;SurfaceMac.mm&lt;/code&gt; that attaches a &lt;code&gt;CAMetalLayer&lt;/code&gt; to a GLFW window's &lt;code&gt;NSView&lt;/code&gt;; the Windows and Linux equivalents are file-shaped holes today. CI builds compile against the abstraction, but the surface code is the actual port.&lt;/p&gt;

&lt;p&gt;The roadmap continues from here: native arcs and dashed strokes via additional pipelines, then HarfBuzz, then compute blur, then a Web target via Dawn plus Emscripten, then Windows and Linux surface layers, then partial-repaint damage rects. Owning the engine means the work is real, but at least it's bounded.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9jwllrxkgu58qjtf7ubs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9jwllrxkgu58qjtf7ubs.png" alt="Images, icons, and inputs in the showcase, all decoded through ImageIO and uploaded as  raw `wgpu::Texture` endraw ." width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The journey is in the git log
&lt;/h2&gt;

&lt;p&gt;The proof that Lume isn't a paper exercise is the diff. The Skia removal was commit &lt;code&gt;0d7a8f4&lt;/code&gt;. The reorganized four-tier repo (Lume in &lt;code&gt;engine/&lt;/code&gt;, the framework in &lt;code&gt;framework/&lt;/code&gt;, the component registry in &lt;code&gt;registry/&lt;/code&gt;, and the .vel compiler in &lt;code&gt;velc/&lt;/code&gt;) is &lt;code&gt;f5b86af&lt;/code&gt;. &lt;code&gt;grep -rE 'Sk[A-Z]|sk_sp' engine framework registry velc&lt;/code&gt; returns zero hits. The dependency list in &lt;code&gt;vcpkg.json&lt;/code&gt; is six lines now, none of them Skia.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dawn          : GPU abstraction (Metal/Vulkan/D3D12/WebGPU)
freetype      : glyph rasterization
harfbuzz      : complex-script shaping (next)
glfw3         : windowing
spdlog        : logging
nlohmann-json : JSON for the framework
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to read it, the code is at &lt;a href="https://github.com/chan27-2/Vel" rel="noopener noreferrer"&gt;github.com/chan27-2/Vel&lt;/a&gt;. The README's &lt;a href="https://github.com/chan27-2/Vel#lume--the-rendering-engine" rel="noopener noreferrer"&gt;Lume section&lt;/a&gt; walks the engine specifically; the &lt;code&gt;engine/&lt;/code&gt; tree on &lt;code&gt;main&lt;/code&gt; is the smallest version of "a 2D GPU rendering engine you can actually run" I know how to write.&lt;/p&gt;

&lt;p&gt;The lesson I'd take from this, and I'm saying this because I want to remember it later, is that &lt;em&gt;the rendering substrate is not a library decision&lt;/em&gt;. It's an architecture decision. The moment your framework needs to answer cross-cutting questions about hit-testing, atlas eviction, draw order, and HiDPI all at once, you can either keep negotiating with someone else's library or you can write your own. Flutter eventually came to the same conclusion. So did I, just earlier. The work is bigger than it looks. The result is that everything downstream of the renderer stops feeling like it's fighting the renderer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpx40qo3dbzg0a30jfplz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpx40qo3dbzg0a30jfplz.png" alt="Lume v1, running the full Vel showcase on macOS." width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>vel</category>
      <category>gpu</category>
      <category>rendering</category>
      <category>webgpu</category>
    </item>
    <item>
      <title>What's new in Vel: hot reload, an SDK, and a real editor</title>
      <dc:creator>Sai Chandan Kadarla</dc:creator>
      <pubDate>Sat, 06 Jun 2026 20:10:03 +0000</pubDate>
      <link>https://dev.to/chan27/whats-new-in-vel-hot-reload-an-sdk-and-a-real-editor-1kfd</link>
      <guid>https://dev.to/chan27/whats-new-in-vel-hot-reload-an-sdk-and-a-real-editor-1kfd</guid>
      <description>&lt;p&gt;When I posted &lt;a href="https://dev.to/blog/building-vel"&gt;Building Vel&lt;/a&gt; a month ago, the language and runtime worked end-to-end — but everything around them was painful. You had to clone the repo, wait 30–60 minutes for vcpkg to build Skia, restart the binary on every change, and write &lt;code&gt;.vel&lt;/code&gt; files in a plain text editor with no help.&lt;/p&gt;

&lt;p&gt;Here's what shipped to fix that. None of it changed what Vel &lt;em&gt;is&lt;/em&gt;. All of it changed what using Vel &lt;em&gt;feels like&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Flutter-style SDK
&lt;/h2&gt;

&lt;p&gt;The install path is now one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/chan27-2/vel/main/scripts/install-vel.sh | bash
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'eval "$(vel shell-init bash)"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.zshrc
vel doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script downloads a prebuilt SDK archive for your OS and arch — &lt;code&gt;vel-sdk-&amp;lt;ver&amp;gt;-darwin-arm64.tar.gz&lt;/code&gt;, &lt;code&gt;linux-x64.tar.gz&lt;/code&gt;, &lt;code&gt;windows-x64.zip&lt;/code&gt; — drops it at &lt;code&gt;~/.vel/sdk&lt;/code&gt;, and adds &lt;code&gt;vel&lt;/code&gt; + &lt;code&gt;velc&lt;/code&gt; to your &lt;code&gt;PATH&lt;/code&gt;. No Skia compile. No vcpkg bootstrap. First run to working compiler: seconds, not hours.&lt;/p&gt;

&lt;p&gt;Windows users get the equivalent PowerShell line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;irm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://raw.githubusercontent.com/chan27-2/vel/main/scripts/install-vel.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model is Flutter's, deliberately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flutter&lt;/th&gt;
&lt;th&gt;Vel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/flutter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.vel/sdk&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;stable&lt;/code&gt; / &lt;code&gt;beta&lt;/code&gt; channels&lt;/td&gt;
&lt;td&gt;`vel channel stable\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;flutter upgrade&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vel upgrade&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;flutter doctor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vel doctor&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The piece Vel doesn't ship: your final binary still links Skia/GLFW via vcpkg, same as Flutter's platform embedders. The SDK gets you the compiler and the framework — your CMake handles the application link.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hot reload via dynamic plugins
&lt;/h2&gt;

&lt;p&gt;The biggest architectural change since the introduction post. &lt;code&gt;libvel&lt;/code&gt; is now a shared library, and the app shell can &lt;code&gt;dlopen&lt;/code&gt; a plugin — your widget tree compiled into its own dylib.&lt;/p&gt;

&lt;p&gt;The plugin ABI is one symbol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="n"&gt;vel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Widget&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;vel_create_root&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host watches the dylib's mtime every 50ms. When you save a &lt;code&gt;.vel&lt;/code&gt; file, a watch loop runs &lt;code&gt;cmake --build build --target showcase_plugin&lt;/code&gt; (typically 1–2s for a small edit). The dylib mtime ticks. The host destroys the current root, &lt;code&gt;dlclose&lt;/code&gt;s the old handle, &lt;code&gt;dlopen&lt;/code&gt;s the new one, and calls &lt;code&gt;vel_create_root()&lt;/code&gt; again. The window updates in place.&lt;/p&gt;

&lt;p&gt;One script wires it all together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/dev-hot.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the dev loop now: edit, save, see the UI change in under two seconds without restarting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs.&lt;/strong&gt; Signal state is reset on reload. If you had a form half-filled, those values are gone after the dylib swap — the new root is a fresh tree. This is a deliberate tradeoff: serializing state across an ABI boundary is the kind of feature that ends up dictating every widget's API. For a UI dev loop, "edit structure → see new structure" is what matters; the state comes back when you fill the form once.&lt;/p&gt;

&lt;p&gt;The other cost: &lt;code&gt;RTLD_LOCAL&lt;/code&gt; matters. Plugins are loaded with &lt;code&gt;dlopen(path, RTLD_NOW | RTLD_LOCAL)&lt;/code&gt; so symbol tables don't leak across reloads. Without it, the second load picks up stale vtables from the first plugin and crashes on the first virtual call. The teardown order also matters — destroy the old root &lt;em&gt;before&lt;/em&gt; &lt;code&gt;dlclose&lt;/code&gt;, because the destructors run vtables that live in the plugin's text segment.&lt;/p&gt;

&lt;h2&gt;
  
  
  A VS Code extension with real diagnostics
&lt;/h2&gt;

&lt;p&gt;Vel has &lt;a href="https://github.com/chan27-2/vel/tree/main/editors/vscode" rel="noopener noreferrer"&gt;a VS Code extension&lt;/a&gt; now — syntax highlighting, completion, hover, live diagnostics.&lt;/p&gt;

&lt;p&gt;The interesting part is where the data comes from. Two things were already true about &lt;code&gt;velc&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;velc --diagnostics-json file.vel&lt;/code&gt; emits structured parse + typecheck errors.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;velc --docs-json&lt;/code&gt; emits the complete widget registry — every widget, every prop, every event, every example — as JSON.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The extension is mostly a thin TypeScript shim over those two outputs. The language server runs &lt;code&gt;velc --diagnostics-json&lt;/code&gt; on save and surfaces the errors. Completion and hover read from &lt;code&gt;widgets.json&lt;/code&gt; — the &lt;em&gt;same&lt;/em&gt; file the docs site is built from. There is one source of truth for "what widgets and props exist," and the compiler owns it.&lt;/p&gt;

&lt;p&gt;This is the part I'm proudest of. The compiler doing double duty as the registry source means the editor experience never drifts from the language. Add a widget; the editor knows about it on the next sync. Rename a prop; the diagnostics flag every old call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  A live theme configurator
&lt;/h2&gt;

&lt;p&gt;The framework now ships a &lt;code&gt;ThemeConfig&lt;/code&gt; global — accent, radius scale, dark/light — that any widget can flip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Btn "Rose"   variant=outline -&amp;gt; click =&amp;gt; vel::useAccentRose()
Btn "Lg"     variant=outline -&amp;gt; click =&amp;gt; vel::useRadiusLg()
Btn "Light"  variant=outline -&amp;gt; click =&amp;gt; vel::useLightTheme()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 7 accents (Indigo, Blue, Violet, Rose, Emerald, Amber, Slate), 6 radius scales (None → Full), and dark/light. A single setter call rebuilds the &lt;code&gt;Theme&lt;/code&gt; from the config and re-applies it across the tree. The whole UI re-tints in one frame.&lt;/p&gt;

&lt;p&gt;The detail that took thought: this used to crash. The old path called &lt;code&gt;root-&amp;gt;onThemeChanged()&lt;/code&gt; synchronously from inside the click dispatch, which rebuilt the entire widget tree — freeing the button that was still executing its own &lt;code&gt;onClick&lt;/code&gt; handler. Use-after-free, every time.&lt;/p&gt;

&lt;p&gt;The fix is deferred rebuild: set a &lt;code&gt;pendingThemeChange_&lt;/code&gt; flag and apply the theme at the start of the &lt;em&gt;next&lt;/em&gt; frame, after the current event dispatch has unwound. It's now the pattern for any widget action that mutates global state — locale switching will use it next.&lt;/p&gt;

&lt;h2&gt;
  
  
  EditableText: one widget, two surfaces
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Input&lt;/code&gt; and &lt;code&gt;Textarea&lt;/code&gt; shared ~95% of their code — UTF-8 cursor math, blink, key handling, focus sync, char input. They were drifting. Both have been refactored onto a shared &lt;code&gt;EditableText&lt;/code&gt; base:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EditableText&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Interactable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="nl"&gt;public:&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;function&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;function&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;onMouseDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MouseButton&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// final&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;onKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// final&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;onChar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;// final&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// final&lt;/span&gt;

&lt;span class="nl"&gt;protected:&lt;/span&gt;
    &lt;span class="k"&gt;virtual&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;  &lt;span class="n"&gt;byteIndexAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Point&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// override per layout&lt;/span&gt;
    &lt;span class="k"&gt;virtual&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;onEnterPressed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                     &lt;span class="c1"&gt;// Input submits, Textarea inserts \n&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Subclasses now only own their rendering and the click-to-cursor mapping. One place to fix backspace-at-position-zero. One place to keep the cursor on a UTF-8 boundary. Adding a &lt;code&gt;PasswordInput&lt;/code&gt; or a syntax-highlighted code editor is much smaller now — the base class owns the hard parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI on Linux, macOS, and Windows
&lt;/h2&gt;

&lt;p&gt;Less glamorous, but it matters: GitHub Actions builds Vel on Ubuntu, macOS, and Windows on every push and PR. Each platform builds &lt;code&gt;velc&lt;/code&gt;, builds &lt;code&gt;libvel&lt;/code&gt;, runs CTest, and runs the showcase smoke test where a display exists.&lt;/p&gt;

&lt;p&gt;Two things were painful to get right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;vcpkg caching.&lt;/strong&gt; Earlier iterations cached only &lt;code&gt;installed/&lt;/code&gt;, which left the cache unable to bootstrap on restore — the &lt;code&gt;.git&lt;/code&gt; directory and &lt;code&gt;scripts/&lt;/code&gt; were missing. Now the entire &lt;code&gt;vcpkg-root&lt;/code&gt; is cached, keyed on the manifest hash. CI cold start dropped from a full Skia build to a tar extract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headless runners.&lt;/strong&gt; macOS CI has no display; Windows Server runners ship without a usable OpenGL driver, so &lt;code&gt;glfwCreateWindow&lt;/code&gt; aborts the showcase. The &lt;code&gt;vel run&lt;/code&gt; script now detects &lt;code&gt;MINGW*/MSYS*/CYGWIN*&lt;/code&gt; and macOS CI and skips the GUI launch — compile + CTest still cover the toolchain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Multi-platform CI is the kind of thing you don't notice until it breaks. It took three commits to get right, and I hope to never touch it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The showcase, reorganized
&lt;/h2&gt;

&lt;p&gt;A small one but worth flagging: &lt;code&gt;examples/showcase.vel&lt;/code&gt; used to be one long scrolling page exercising every primitive. It's now a multi-page demo with a sidebar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Form Controls&lt;/li&gt;
&lt;li&gt;Buttons &amp;amp; Cards&lt;/li&gt;
&lt;li&gt;Data&lt;/li&gt;
&lt;li&gt;Overlays&lt;/li&gt;
&lt;li&gt;Theme&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build, then &lt;code&gt;./scripts/vel run ./build/showcase&lt;/code&gt; — that's the first thing you see. It's also the integration test that exercises every widget, every prop, and every event.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-widget damage rectangles.&lt;/strong&gt; Frame-level damage tracking is great for idle apps; per-widget would let animations not repaint the whole screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vel.json&lt;/code&gt; package manifest&lt;/strong&gt;, so third-party registries become installable instead of vendored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;vel new&lt;/code&gt; template generator.&lt;/strong&gt; The boilerplate to start a Vel project is small but not zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More registry primitives&lt;/strong&gt; — Video, SVG, a devtools overlay, a syntax-highlighted code editor (which the &lt;code&gt;EditableText&lt;/code&gt; refactor now makes practical).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you tried Vel a month ago and bounced off the install, give it another go. &lt;code&gt;curl -fsSL .../install-vel.sh | bash&lt;/code&gt;, run &lt;code&gt;vel doctor&lt;/code&gt;, open the showcase. The first ten minutes look very different now.&lt;/p&gt;

</description>
      <category>uilang</category>
      <category>cpp</category>
      <category>dsl</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
