# Dennis Morello — Full Content > Dennis Morello, Lead Frontend Engineer from Milan. 10+ years building accessible web interfaces and open-source libraries like React Awesome Reveal. ## Projects - **Arbor** (https://github.com/morellodev/arbor): A friendly CLI for managing git worktrees. - **Clipwise AI** (https://www.getclipwise.app): Chrome extension that converts webpages to clean Markdown for ChatGPT, Claude, and other AI assistants. - **Defrag98** (https://defrag98.com): A simulator of the classic Windows 98 Disk Defragmenter tool. - **Investment Simulator** (https://money.morello.dev): A simple investment simulator to help you understand the power of compound interest. - **React Awesome Reveal** (https://github.com/awesome-reveal/react-awesome-reveal): Performant and easy-to-use animation library for React apps. ## Talks - **Writing Accessible Components** (https://youtu.be/wfjjFjoyRd0, 2022-11-09): Learn how to build React components that are accessible by default, with a live coding session building an accessible accordion from scratch. --- # Why I Replaced Tailwind CSS With Vanilla CSS URL: https://morello.dev/blog/replacing-tailwind-with-vanilla-css Published: August 6, 2026 Tags: css, tailwindcss, webdev, astro I pulled Tailwind CSS out of my Astro site and rewrote it in vanilla CSS. Here's why modern CSS made the framework optional in 2026, and how I did it. I removed Tailwind CSS from this site and rewrote every style by hand in vanilla CSS. The reason is short: modern CSS now does natively what I was pulling Tailwind in to get. Cascade layers, custom properties, nesting, and `:has()` all ship in every current browser, and Tailwind v4 is built on those exact primitives. Once the browser speaks the language directly, a build step that generates it for you reads as overhead rather than leverage. When I [rebuilt this site in December 2025](/blog/the-new-website) I reached for [Tailwind v4](https://tailwindcss.com) on instinct. This month I took it back out. ## TL;DR - The features I used Tailwind for (design tokens, nesting, scoping, a consistent scale) are now native CSS or handled by Astro's scoped styles. - Tailwind v4 is itself built on native cascade layers, custom properties, and `color-mix()`, with [Lightning CSS](https://lightningcss.dev) doing the build. I was shipping an engine to generate CSS the browser already understands. - The migration dropped three Tailwind packages, moved global styles into native `@layer`s, and put every component's styles in Astro's scoped ` ``` The markup goes quiet and the intent moves into CSS that reads like CSS. Native nesting holds the `:has()` state rule next to the base rule, and a normal media query handles the responsive padding that `sm:p-6` used to encode. [Astro scopes these styles](https://docs.astro.build/en/guides/styling/#scoped-styles) through a generated `data-astro-cid` attribute, so a bare `article` selector is enough and the element needs no class of its own. And because scoped styles compile unlayered, they beat the global layers without any specificity games. That last property is what made the migration safe to do component by component: a scoped rule always wins over a global primitive, so I could move one file at a time without the two systems fighting. ### Lightning CSS does the vendor work The reflex worry with dropping a framework is that you inherit its chores: prefixing, downleveling new syntax, minifying. I inherited none of them, because [Lightning CSS](https://lightningcss.dev) was already in the pipeline. It's a Rust parser, transformer, and minifier by [Devon Govett](https://x.com/devongovett), and it is the same tool Tailwind v4's Oxide engine depends on. Astro's Rust compiler [uses it too](/blog/astro-7), so turning it on for my own CSS was one line: ```js vite: { css: { transformer: "lightningcss" }, build: { cssTarget: ["chrome111", "edge111", "firefox114", "safari16.4", "ios16.4"], }, } ``` It adds the handful of `-webkit-` prefixes my target browsers still need, lowers nesting for the stragglers, and minifies, all against a Baseline target set. The `package.json` diff was the satisfying part: out went `tailwindcss`, `@tailwindcss/vite`, and `@tailwindcss/typography`; in went a single `lightningcss` dev dependency that was effectively already there. The long-form article styling followed the same instinct I wrote about in [shadcn/typeset vs Tailwind Typography](/blog/shadcn-typeset-vs-tailwind-prose): own the file, delete the rules you don't want. ## Did the site stay Baseline compliant? Yes, and that was a hard constraint, not a nice-to-have. Every feature this site ships has to be [Baseline Widely available](/blog/baseline-2026-web-platform-apis), interoperable across the core browsers for at least 30 months. That's exactly why the timing worked: the two features I lean on hardest, native nesting and `:has()`, only reached Widely availability around June 2026. If I'd tried this a year earlier they'd have been Baseline Newly at best, and the card component above would have needed an `@supports` gate with a full fallback. Doing it now, they're safe to use unguarded. The one place I still gate behind `@supports` is the anchor-positioned reading indicator in the table of contents, which is genuinely still Newly available. ## Do you still need Tailwind in 2026? Only if someone other than you owns the CSS. That is the honest split, and it decides whether replacing Tailwind with vanilla CSS is worth it for you: - **A team with a shared design system:** probably keep Tailwind. The constraints that feel like overhead on a solo site are the whole point when twenty people style the same product. Consistency you don't have to enforce by review is worth a build step. - **A solo project or a content site:** the trade-off has flipped. You get design tokens from custom properties, override control from cascade layers, nesting and state selectors natively, and scoping from your framework's components. The framework's value is real but no longer necessary. I'm not claiming Tailwind lost. [Kevin Powell](https://kevinpowell.co) has spent years arguing that learning CSS itself pays off, and the platform finally caught up to make that cheap. Tailwind v4 is a genuinely good piece of engineering, built on the same modern CSS that made me comfortable leaving it. For this site, hand-written CSS wired to theme variables is less code, one fewer dependency, and output I can read top to bottom. That's the same own-the-source call I keep making here, and this time the browser did most of the work for me. --- # Bun Rust Rewrite: 64 Claude Agents, 535k Lines of Zig URL: https://morello.dev/blog/bun-14-rust-rewrite Published: August 1, 2026 Tags: rust, javascript, ai, webdev Bun 1.4's Rust rewrite hits canary: 64 Claude agents ported 535k lines of Zig in 11 days. What it fixes, what it overclaims, who's right: Sumner or Kelley. A couple of weeks ago, in the [post about pnpm rewriting its install engine in Rust](/blog/pnpm-v12-rust-rewrite), I called Bun "the odd one out: it's written in Zig, not Rust." That line is wrong, and the awkward part is that it was wrong the week I wrote it. Jarred Sumner announced Bun's Zig-to-Rust rewrite on July 8; the pnpm post went up July 15, so the claim was stale at publication. I've since corrected that post so it acknowledges the rewrite and links here, which means if you click through you won't find the quoted line anymore. [Bun](https://bun.sh) is rewriting itself from Zig to Rust, and the rewrite is in canary for Bun 1.4, the first release where the runtime, bundler, and test runner are all Rust. [Jarred Sumner](https://x.com/jarredsumner) didn't do it by hand. He ran about 50 Claude Code workflows over 11 days, a few at a time, and let them loose on 535,496 lines of Zig. This is the part of the JavaScript toolchain story I want to get right, because it's the one most likely to be flattened into a press release. The headline is real: Bun 1.4 fixes 128 bugs, ships a roughly 20% smaller binary, and runs a bit faster. The headline is also doing some work the underlying numbers don't fully support, and there is a genuine, public argument between Sumner and [Andrew Kelley](https://andrewkelley.me), the creator of Zig, over what actually happened. Both of them are partly right. ## TL;DR - Bun ported its entire runtime from Zig to Rust in 11 days with 64 Claude agents, and the rewrite is in canary for Bun 1.4, not stable. - The numbers that hold up are the leak fixes. An in-process build loop that ballooned to 6.7 GB in 1.3.14 levels off at 609 MB in 1.4, and the use-after-free and double-free bugs that dogged the Zig runtime are the class of failure safe Rust's `Drop` and borrow checker turn into compile errors. - What the announcement overclaims is the framing. The 2 to 5% speed gain and the roughly 20% smaller binary come from cross-language LTO, ICU trimming, and linker work that could have shipped in Zig, not from Rust as a language. - Don't move to 1.4 in production yet. Stable is still v1.3.14; run `bun upgrade --canary` on a throwaway branch, hammer the 2,000-build leak scenario against whatever you ship, and watch memory hold flat before you trust it. ## What the Bun Rust rewrite actually is Bun 1.4 is a mechanical port of the whole runtime from Zig to Rust, done one `.zig` file at a time, holding behavior as close to 1.3.14 as the port allows. The last Zig release is [Bun v1.3.14](https://github.com/oven-sh/bun/releases/tag/bun-v1.3.14), tagged May 13, 2026. The first Rust release is v1.4.0, currently [in canary](https://bun.com/blog/bun-in-rust): run `bun upgrade --canary` to pull it. "Mechanical port" is the load-bearing phrase. This is not a greenfield rewrite where Claude gets to redesign Bun. The Zig and Rust shown side by side in Sumner's [announcement](https://bun.com/blog/bun-in-rust) are deliberately near-identical: same function names, same scoping, same comments. The plan is to refactor toward idiomatic Rust *after* 1.4 ships, not during the port. That choice is what makes an 11-day timeline plausible, and it's what keeps the existing TypeScript test suite meaningful as the source of truth. The tests were never tied to the implementation language, so a faithful port should pass the same assertions. The scope is everything Bun is: the JavaScript, TypeScript, and CSS transpiler, minifier, and bundler; the npm-compatible package manager; the Jest-like test runner; the Node.js API surface (`fs`, `net`, `tls`, `http`, `http2`, `node:zlib`); and the HTTP server. About 780,000 lines of Rust now sit where 535,496 lines of Zig used to. The "million lines" Kelley keeps citing is the diff: the announcement counts the port as adding a million-plus lines of new Rust. The 780,000 is the resulting codebase, the denominator for the unsafe-percentage figure. Both numbers are right; one is what went in, the other is what's there. ## Why rewrite Bun from Zig to Rust? The honest case isn't speed. It's stability. Sumner opens the [announcement](https://bun.com/blog/bun-in-rust) with a list of bugs fixed in 1.3.14, bugs the Zig runtime shipped until that release, and it's grim reading for a runtime people run in production: - heap-use-after-free in `node:zlib` when `.reset()` fires during an async `.write()` still in flight on the threadpool - use-after-free in `node:http2` when a reentrant JS callback triggers a hashmap rehash that invalidates internal stream pointers - use-after-free in `UDPSocket.send()` where a `valueOf()` callback detaches the `ArrayBuffer` between payload capture and the actual send - a `tlsSocket.setSession()` leak of about 6.5 KB per call, every call, from a missing `SSL_SESSION_free` - `fs.watch()` watchers never collected after `.close()`, pinned as GC roots by a reference-count underflow These are the canonical memory-safety failures: use-after-free, double-free, leaks on error paths. In safe Rust most of them become compile errors, and the rest get `Drop` running cleanup exactly once when a value goes out of scope. The argument is that compiler errors are a better feedback loop than a style guide enforced by review, and for this class of bug, lifetimes of garbage-collected values mixed with manually managed ones, it's hard to argue with. Zig's `defer`/`errdefer` model puts cleanup at every call site and trusts the author to get it right every time. Bun, at half a million lines, did not get it right every time, and nobody reasonable expected it to. This is the part I'd already half-gotten wrong. When I lumped Bun into the "everything's Rust now" trend in the [pnpm v12 post](/blog/pnpm-v12-rust-rewrite) and flagged it as the Zig holdout, I treated language choice as a settled, one-way decision. Sumner says explicitly that it used to be one, and that AI-assisted porting is what made it reversible at this scale. Take that claim seriously even if you're skeptical of the rest. ## How do you port 535,000 lines in 11 days? You don't ask one agent to "rewrite Bun." Sumner's post is most useful as an engineering breakdown of the loops, and the loops are ordinary: ```js // Pseudocode, not real code: let task; while ((task = todoList.pop())) { const result = task(); const feedback = await Promise.all([review(result), review(result)]); await apply(feedback, result); } ``` Each workflow is one implementer plus two or more adversarial reviewers plus a fixer, with the reviewers running in separate context windows so the Claude that wrote the code isn't the Claude grading it. At peak, four workflows ran at once, each with 16 Claudes, for about 64 concurrent agents across four [git worktrees](/blog/git-worktrees-are-underrated). They started on May 3 and merged May 14, across 6,778 commits and 1,448 `.zig` files moved to `.rs`. The part worth copying is the prep work. Before any porting, Sumner spent about three hours with Claude producing a `PORTING.md` that mapped Zig patterns to Rust patterns, then ran a workflow that traced every struct field's lifetime across the codebase and wrote the results to a `LIFETIMES.tsv`. Three files were ported first as a trial, reviewed against the guides, then the full sweep ran. The false starts are instructive: two Claudes ran `git stash` and `git reset --hard` over each other's work, so the workflow rule became "no `git` except committing one specific file, no `cargo`, no slow commands." Claude also tried to "fix" compile errors by stubbing functions out, so a rule was added: if a workaround needs a paragraph-long comment, the code is wrong, fix the code. The token bill for all of this is roughly 5.9 billion uncached input tokens, 690 million output tokens, and 72 billion cached reads, about $165,000 at API pricing, on what was then a pre-release build of Claude Fable 5, the Mythos-class model Anthropic shipped a month later. That's the part the search summaries will quote and the part that most obscures what happened. The money bought an engineered loop with human supervision, not a prompt and a prayer. ## Do the numbers hold up? Here's the skeptic's reading. The [announcement](https://bun.com/blog/bun-in-rust) lists 128 bugs fixed versus 1.3.14, 19 known regressions (all fixed), zero tests skipped or deleted, and 1,386,826 `expect()` calls on Debian. The leak numbers are the most convincing: every in-process `Bun.build()` leaked about 3 MB in 1.3.14, so 2,000 builds ran the process to 6,745 MB. In 1.4 it levels off at 609 MB. | Builds | Bun 1.3.14 | Bun 1.4 (canary) | | -----: | ---------: | ---------------: | | 500 | 1,914 MB | 526 MB | | 1,000 | 3,506 MB | 586 MB | | 1,500 | 5,097 MB | 608 MB | | 2,000 | 6,745 MB | 609 MB | If you run a dev server that bundles on every request, that's the difference between an OOM restart at 3 AM and a stable process. A previous Zig attempt at this fix [never merged](https://bun.com/blog/bun-in-rust) because, Sumner says, the lack of `Drop` made it too risky to trust. That's the strongest single argument for the rewrite. The speed and size numbers want more scrutiny. Bun 1.4 is 2 to 5% faster on HTTP and app workloads: `Bun.serve` goes 169.6k to 177.7k req/s (up 4.8%), and `next build` goes 13.62s to 13.03s. The binary shrinks roughly 20%, from 94 to 76 MB on Windows and 88 to 70 MB on Linux. But the announcement itself attributes the throughput to cross-language LTO between C/C++ and Rust, and attributes the binary shrink partly to ICU trimming and Identical Code Folding. None of that is Rust-only. The initial Rust-only binary reduction came from dropping Zig's heavy `comptime` use; the rest came from linker work that could have shipped in Zig. That's where Andrew Kelley's pushback lands hardest. The short version: a meaningful slice of the "Bun is better in Rust" win was engineering work Bun could have shipped without changing languages, and the post doesn't separate the two clearly. ## The Sumner vs. Kelley fight, both sides [Andrew Kelley's response](https://andrewkelley.me/post/my-thoughts-bun-rust-rewrite.html) is not a calm post, and it's not framed as a technical critique. It opens on Sumner's "beginner energy" and builds to Kelley calling him "a stinky manager. Poor communication, unrealistic expectations, low empathy, no experience." But buried in the personal framing are four technical objections that hold up on their own: 1. The test-suite contradiction. The argument for merging a million lines of largely unreviewed code is that the test suite catches everything. But the same post opens with a long list of bugs in the Zig version the test suite didn't catch. Kelley's question: if it wasn't sufficient to catch Zig bugs, why is it sufficient to declare one million lines of unreviewed AI-authored Rust clean? 2. The style-guide sleight of hand. The post frames the choice as "style guide vs. language feature," which skips the main way projects actually eliminate bugs, which is putting engineers on it. Kelley points at TigerBeetle, another Zig project, as the team that did the work Bun didn't. 3. The fuzzing claim. The announcement implies diligent Zig-side fuzzing; Kelley says Bun told the Zig Software Foundation directly that they weren't fuzzing anything. The 24/7 coverage-guided fuzzing the post now brags about is new and Rust-side. 4. The omitted build speed. The Zig compiler, roughly 600,000 lines, builds clean in 16s and recompiles in 90ms with incremental enabled, Kelley writes. Bun's post doesn't give post-rewrite build numbers, and for a mechanical port of a codebase this size that's a conspicuous gap. Each of those is fair. The first is the sharpest. Sumner's "0 tests deleted, 1.4M expect() calls, adversarial review, human in the loop" defense addresses process, not the logical gap. A suite that missed a `setSession` leak bleeding 6.5 KB a call for years is not a suite you can then cite as proof a million lines are correct. The suite is necessary and it caught a lot, the 128 fixes prove that, but "the test suite caught regressions" and "the test suite proves correctness" are different claims, and the announcement slides between them. Sumner's side holds up too, and it's the one that matters more for users. The Zig bug list isn't marketing; those are real crashes and real leaks in a runtime with 22 million monthly CLI downloads that now backs Claude Code. The `Drop` argument is the real argument, and Kelley doesn't really answer it. He shifts to "you should have put in engineering hours instead," which is true and also not a refutation of the fact that Zig's manual-lifetime model was bleeding. You can fault how Bun got to Rust and still accept that being in Rust, with `Drop` and a borrow checker and Miri, materially lowers the chance of the next `setSession` leak. Where I land: the rewrite is net good for Bun's users, the AI-authorship process is more disciplined than its critics assume, and the announcement overclaims by bundling genuinely Rust-driven gains (leaks, use-after-free) with engineering work that didn't need a rewrite (LTO, ICU, `comptime` cleanup). Read the two posts as correcting each other and you get closer to the truth than either one alone. ## What about the `unsafe` code and undefined behavior? The thing people will actually worry about with AI-authored Rust is `unsafe`. The announcement gives the number: about 4% of the Rust sits in `unsafe` blocks, roughly 13,000 `unsafe` keyword uses across the ~780,000-line codebase, and 78% of those blocks are a single line, a pointer from JavaScriptCore or one call into a C library. That's lower than I expected, and it's the floor, not a ceiling. Bun embeds JSC, uWebSockets, BoringSSL, and SQLite, so `unsafe` will never hit zero. The plan is to push it down as the mechanical port gets refactored toward idiomatic Rust. The worry has a concrete footprint. Issue [#30719](https://github.com/oven-sh/bun/issues/30719), filed May 14 by AwesomeQubic, is titled "PathString::slice dangling reference UB - add Miri to CI": a `core::slice::from_raw_parts` call constructing a dangling `&[u8]`. Miri was not in Bun's CI when the bug was filed; the reporter ran it locally to find it. The issue is closed, fixed by [#30876](https://github.com/oven-sh/bun/pull/30876) ("Add cargo-miri support and fix HiveArray aliasing UB", merged May 17), with [#30728](https://github.com/oven-sh/bun/pull/30728) an earlier attempt still open on GitHub. The reporter's parting line, "Please consider not vibe coding rust as AIs are not good at writing Rust and also hire a real rust dev," is the tweet-sized version of the whole debate. The bug is real, the kind of UB that turns up in any large `unsafe`-touching codebase, and Miri is exactly the tool meant to catch it. What's worth being precise about is who supplied the mechanism: the reporter ran it on a build Bun hadn't instrumented yet, not Bun's CI. The announcement now says Miri runs on a growing chunk of code in CI, which is the state after #30876 landed. So it's still the system working, not failing, but it's the community catching the bug and Bun turning the catch into infrastructure. And it's the proof that "AI wrote it" doesn't free you from reviewing `unsafe`. ## Where this leaves the JavaScript toolchain The pattern is established enough that I'd stop calling it a trend and start calling it the default. [TypeScript](/blog/typescript-7-is-here) went to Go for build speed. [pnpm](/blog/pnpm-v12-rust-rewrite) is going to Rust for install speed. [Astro 7](/blog/astro-7) rebuilt its compiler in Rust. Deno is Rust from the ground up. Bun just went to Rust for stability. The reasons differ, but the move is the same: the JavaScript world's foundation is being rewritten in languages with a real memory model, and the engines running the JS itself, V8 and JavaScriptCore with their [tiered JIT pipelines](/blog/five-things-you-might-not-know-about-javascript), are the one layer staying put. What's new in the Bun story is who's doing it and who owns it. Anthropic [acquired Bun in December 2025](https://bun.com/blog/bun-joins-anthropic). Claude Code ships as a Bun executable to millions of users, and the rewrite ran on Claude Fable 5 a month before Anthropic shipped it. Prisma launched a Prisma Compute beta on the Rust rewrite; Alexey Orlenko is quoted in the [announcement](https://bun.com/blog/bun-in-rust) on the memory leaks and the connection pool "that couldn't recover after a VM was paused and resumed," handled "perfectly" by the Rust build. That's the financial and structural context the announcement's lead leaves out: this is Anthropic making its own tooling's runtime safer, with Anthropic's model. The independence question is real, even if the code stays MIT. The subtler shift is what "an engineer can do in a year" means. Sumner's closing line, that one engineer can do a lot more today than a year ago, is the part that's broader than Bun. If a faithful port of half a million lines, supervised, costs $165k and 11 days, the bottleneck for a class of large rewrites stops being the typing and starts being taste: knowing which port to do, how to scope it, and how to review the output. Bun's loop is a template people will copy, and the teams that internalize it first get to attempt things that used to require freezing development for a year. ## Should you run Bun 1.4 today? If you're on Bun in production on 1.3.x, the answer is "not yet, but soon, and watch the bug list." As I write this, the latest stable tag in Bun's [release feed](https://github.com/oven-sh/bun/releases.atom) is still v1.3.14 from May 13. The only newer entries are `consolidation-step-N-green` canary tags from Bun's automation, not a stable v1.4.0. To try it on a throwaway branch: ```sh bun upgrade --canary ``` The honest test isn't the benchmark. Run the leaky-build scenario from the announcement, 2,000 `Bun.build()` calls in one process, against whatever you ship, and watch memory. If it holds flat the way the announcement's table shows, the rewrite did the thing you actually care about. If it doesn't, file the bug, because the whole premise is that this class of issue is now catchable and fixable instead of permanent. I wrote that Bun was the Zig holdout two and a half weeks ago, and the line was stale the week it went up: Sumner had already announced the rewrite. The less comfortable lesson, for anyone writing about this stack, is that "X is written in Y" is now a claim with a short half-life, short enough that being a week behind the news is enough to be wrong. Bun's Rust rewrite is the cleanest example I've seen of AI-assisted porting at a scale that used to take a year, and the open questions about it, how much of the win was the language versus the engineering hours, whether machine-authored `unsafe` can be trusted, who owns the runtime your agent runs on, are the ones the rest of the JavaScript toolchain is going to answer next, one rewrite at a time. --- # Baseline 2026: 4 APIs That Changed How I Code URL: https://morello.dev/blog/baseline-2026-web-platform-apis Published: July 22, 2026 Tags: webdev, javascript, css Navigation API, container style queries, :open, and Math.sumPrecise() all became Baseline in 2026. The ones that replaced actual dependencies in my projects. Baseline 2026 brought a wave of new interoperable web platform APIs. Not the kind of big where everything changes overnight. The kind where APIs that have been experimental for years finally ship in every browser. Baseline means you can use them in production without a polyfill, and the 2026 cohort has a handful that actually changed what I reach for when I start a new project. Not "technically you could," but "I stopped installing this." ## The Navigation API: one `navigate` event instead of three different APIs The Navigation API [became Baseline Newly available in early 2026](https://web.dev/blog/baseline-navigation-api), supported in Chrome, Edge, Firefox 147, and Safari 26.2. It replaces the fragmented mess that was History API routing: `pushState` for navigation, `popstate` for back/forward, and manual link click handlers to prevent full-page reloads. Three separate APIs you had to wire together yourself, or reach for a router library that did it for you. Here is what client-side routing looked like before: ```js // Before: three separate concerns, manually wired window.addEventListener("popstate", (e) => { renderRoute(window.location.pathname); }); document.addEventListener("click", (e) => { const link = e.target.closest("a[data-route]"); if (link) { e.preventDefault(); history.pushState(null, "", link.href); renderRoute(new URL(link.href).pathname); } }); // Initial render renderRoute(window.location.pathname); ``` And here is the Navigation API equivalent: ```js // After: one centralized event navigation.addEventListener("navigate", (e) => { const url = new URL(e.destination.url); e.intercept({ handler() { renderRoute(url.pathname); }, }); }); ``` One event. One interception point. The `intercept()` call tells the browser "I'll handle this navigation, wait for me," which also means the browser can show a loading indicator natively. You get scroll restoration (`e.scroll()`), form data access (`e.formData`), and entry traversal (`navigation.traverseTo(key)`) without wiring any of it yourself. This does not kill React Router or TanStack Router. Both have open discussions about adopting the Navigation API as a backend: [React Router's](https://github.com/remix-run/react-router/discussions/11046) and [TanStack Router's](https://github.com/TanStack/router/discussions/821). They would sit on top of it rather than reimplement the History API workaround they currently maintain. The frameworks add value (nested routes, data loading, error boundaries) that the raw Navigation API does not. What the API kills is the need to pull in a router *just* to avoid full-page reloads on a content site. If your routing needs are modest (a blog, a docs site, a dashboard with a few views), the platform now has you covered. The caveat: Safari 26.2 is [missing `precommitHandler`](https://www.infoq.com/news/2026/05/navigation-api-browser) support, which limits some advanced interception patterns. And Ian Hickson, the spec author, [famously called](https://html5doctor.com/interview-with-ian-hickson-html-editor) `pushState()` his "favourite mistake," so the replacement had a low bar to clear. ## Container style queries: theming without JS class toggling Container style queries for custom properties [became Baseline Newly available in May 2026](https://web.dev/blog/web-platform-05-2026), with Firefox 151 shipping the last piece and Chrome 148 adding name-only container queries the same month. This one solves a problem I have written far too much JavaScript for: switching visual variants based on context. A card that renders differently in a sidebar vs. a hero section. A theme toggle that propagates across the component tree. All done with CSS classes, JS observers, or both. Here is the old pattern: ```jsx // Before: JS class toggling for a card variant function Card({ variant = "default" }) { return (

{title}

{excerpt}

); } ``` ```css .card--featured { /* special styles */ } .card--compact { /* compact styles */ } ``` And with container style queries: ```css .sidebar { container-name: sidebar; /* no container-type needed for style queries */ } .card { /* default styles */ } @container sidebar style(--variant: featured) { .card { /* featured layout */ } } @container sidebar style(--compact: true) { .card { /* compact layout */ } } ``` The container declares its context via CSS custom properties, and descendant elements react. No JS. No class chains. The component does not need to know where it is rendered; the container owns that relationship. The limitation worth mentioning: [style queries currently only support custom properties](https://github.com/mdn/content/issues/44701). The spec allows querying any CSS property, but no browser ships that yet. So you query `style(--theme: dark)`, not `style(background-color: black)`. For the use case it solves (theming, layout variants, contextual styling): custom properties are exactly what you want. For arbitrary CSS property introspection, we are still waiting. ## `:open`: state tracking you never write The [`:open` pseudo-class became Baseline Newly available in May 2026](https://web.dev/blog/web-platform-05-2026) when Safari 26.5 shipped it. It matches any element with an open semantic state: ``, `
`, `` / `` with their pickers open. Before `:open`, styling an open disclosure meant either an attribute selector or JS: ```css /* Before: attribute selector, limited */ details[open] > summary { border-radius: 4px 4px 0 0; } ``` ```js // Before: manual state tracking for a dialog dialog.addEventListener("toggle", () => { document.body.classList.toggle("dialog-open", dialog.open); }); ``` With `:open`: ```css details:open > summary { border-radius: 4px 4px 0 0; } /* Style the page when any dialog is open */ html:has(dialog:open) { overflow: hidden; } /* Style a label when its associated select is open */ label:has(select:open) { color: var(--accent); } ``` The `:has()` combo is where this gets useful. `html:has(dialog:open)` is a body scroll lock in one line of CSS. `label:has(select:open)` is a parent style change driven by a child's state: something that used to require a JS mutation observer or a framework binding. The `:open` selector tracks semantic state, not visibility: a `
` that is semantically open but visually hidden still matches `:open`. That distinction matters for things like `display: none` inside an open disclosure. This replaces the `details[open]` attribute selector (which only works for `
`) and the pattern of manually toggling CSS classes on `` or parent elements when modals or pickers open. One pseudo-class, all openable elements, no JS. ## `Math.sumPrecise()`: the "wait, `reduce` is wrong?" moment `Math.sumPrecise()` [became Baseline Newly available in April 2026](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sumPrecise), part of ES2026 after [reaching TC39 Stage 4](https://socket.dev/blog/tc39-advances-11-proposals-for-math-precision-binary-apis-and-more) in July 2025. The problem it solves is floating-point summation error: ```js const numbers = [1e20, 0.1, -1e20]; numbers.reduce((a, b) => a + b, 0); // 0 Math.sumPrecise(numbers); // 0.1 ``` The naive `reduce` approach loses precision because floating-point addition is not associative. When you add a very small number to a very large one, the small number disappears. `Math.sumPrecise()` uses the [Shewchuk algorithm](https://github.com/tc39/proposal-math-sum) (or an equivalent) to produce the maximally correct answer: the result you would get with arbitrary-precision arithmetic rounded back to a float. What it does NOT fix: `0.1 + 0.2`. That is a floating-point *representation* problem, not a summation problem. `Math.sumPrecise([0.1, 0.2])` still returns `0.30000000000000004` because the literals `0.1` and `0.2` are inexact before `sumPrecise` ever sees them. The API is deliberately not variadic (`Math.sumPrecise(1, 2, 3)` throws). It takes an iterable to avoid stack overflows on large datasets. Empty iterables return `-0` (the floating-point additive identity). Non-number elements throw a `TypeError`, unlike `Math.max` which silently coerces. TypeScript does not ship type definitions for it yet ([issue #63427](https://github.com/microsoft/TypeScript/issues/63427)), so you may need a manual declaration depending on your [TypeScript 7](/blog/typescript-7-is-here) version. This is not the API that will headline anyone's year-end retrospective. But it fixes a real footgun: the kind of bug that passes code review because nobody spots the summation error in a 50-item array spread across a few chained `.map()` and `.filter()` calls. I wrote about [JavaScript gotchas](/blog/five-things-you-might-not-know-about-javascript) before, and floating-point behavior is the category that surprises experienced developers the most. Having a built-in that handles the common case correctly is a quiet win. ## What "Baseline Newly available" actually means for Baseline 2026 All four of these APIs are Baseline *Newly* available, not Widely available. That distinction matters: - **Newly available:** All core browsers (Chrome, Edge, Firefox, and Safari across desktop and mobile) support the feature. You can use it in production. - **Widely available:** The feature has been interoperable for 30 months. You can use it without thinking about browser support. These four are firmly in the "use it today" category. All core browsers ship them. If you still support older Safari or Firefox versions, feature detection is straightforward: `"navigation" in window`, `CSS.supports("selector(:open)")`, `@supports (container-name: x)` in CSS. None of these require a polyfill to degrade gracefully; they just need a fallback path. The 30-month clock to Widely availability means these become "don't even think about it" territory between mid-2028 and late 2028. ## The web platform is eating libraries from the edges in None of these four APIs kills a major framework. You will still reach for React Router if your app has nested layouts, data loaders, and route-level error boundaries. You will still use a CSS-in-JS library if your design system demands it. What they kill is the dependency you pulled in for *one* thing: the router for a content site, the classnames utility for variant switching, the state tracking for modal-open classes. The gap between "what the platform gives you" and "what you need a library for" keeps narrowing. [The HTTP QUERY method](/blog/the-new-http-query-method) landed earlier this year for safe reads with a body. Now we have routing, variant-driven styling, state selectors, and precise math, all Baseline, all in every browser. We are not at zero dependencies. But each Baseline release shrinks the surface area that justifies pulling in someone else's code. And 2026 has been a good year for that. --- # Astro 7: What's New, What's Faster, and What Breaks URL: https://morello.dev/blog/astro-7 Published: July 21, 2026 Tags: astro, webdev, javascript, rust Astro 7 ships a Rust compiler, Sätteri markdown, Vite 8 with Rolldown, and advanced routing. Builds 15-61% faster. What changed, what breaks, how to upgrade. Astro 7, released [June 22, 2026](https://astro.build/blog/astro-7), is a speed release. The `.astro` compiler got rewritten in Rust, the Markdown pipeline got rebuilt in Rust, and the bundler switched to Vite 8 with Rolldown. It is the same framework on faster infrastructure. Builds on real production Astro sites are 15 to 61 percent faster, with the biggest wins on sites where Markdown and `.astro` compilation dominate the build. ## What Astro 7 actually is Astro 7 is an infrastructure release. The component format, the island architecture, the routing model: all the same. What changed is what sits underneath. The [official announcement](https://astro.build/blog/astro-7) calls it the speed release, and that is the honest framing. Four systems got rebuilt or swapped, five experimental features went stable, and the result is faster builds without a new mental model. The closest parallel is [TypeScript 7 rewriting its compiler in Go](/blog/typescript-7-is-here): keep the surface still, rebuild the engine. Astro 7 does the same, with Rust, across more layers. ## Vite 8 and Rolldown Astro 7 upgrades to [Vite 8](https://astro.build/blog/astro-7), which ships [Rolldown](https://rolldown.rs) as its bundler. Rolldown is a Rust bundler that replaces both esbuild and Rollup in the Vite pipeline, and runs 10 to 30 times faster than Rollup in benchmarks. If you had `esbuild` options or `rollupOptions` in your config, Vite 8 auto-converts them. The plugin API is the same, so existing Vite plugins keep working. Vite is Astro's build core: dev server, dependency optimization, production bundling. A faster bundler there is a faster everything. ## Astro 7's new Rust compiler The `.astro` compiler was rewritten in Rust. It was Go before. The new compiler is built on [oxc](https://oxc.rs) and [Lightning CSS](https://lightningcss.dev), and Astro reports about a 6 percent improvement in isolation. That sounds modest next to the Vite 8 jump, and it is. The compiler was already fast. But 6 percent compounds on a large build that runs thousands of `.astro` files. Two behavior changes, both making the compiler stricter. It no longer auto-corrects HTML: markup is treated as-is. Unclosed tags are now errors, not warnings. If you had sloppy markup the Go compiler quietly fixed, the Rust compiler will tell you. It ships as native binaries per platform, with a WASM fallback. ## Sätteri: Markdown and MDX in Rust Sätteri is the new default Markdown and MDX pipeline. It is Rust-powered, built by Erika on [pulldown-cmark](https://github.com/pulldown-cmark/pulldown-cmark) and oxc, and it replaces the unified/remark/rehype stack Astro used before. The performance is real. Astro's docs build and Cloudflare's docs build each shed over a minute. On a content-heavy site like this one, that is the win you feel. Sätteri ships built-in support for GFM, smartypants, heading IDs, directives, math, frontmatter, and wikilinks. The plugin API works differently from unified: plugins declare the node types they care about, and Sätteri routes only those. If you need the old stack, `unified()` is still available via [`@astrojs/markdown-remark`](https://docs.astro.build/en/guides/upgrade-to/v7/). But for most sites, Sätteri is the default and the faster path. ## Queued rendering is now the default Queued rendering was experimental in Astro 6. In Astro 7 it is stable and default. The old renderer walked the component tree recursively. Queued rendering uses a queue-based approach instead, and Astro reports roughly 2.4x faster rendering on expression-dense pages. You do not configure this. It is just on. The `queuedRendering` experimental flag is gone. ## Advanced routing with src/fetch.ts Advanced routing gives you a `src/fetch.ts` entrypoint with full control over the request pipeline. It uses the standard [fetch handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/) pattern, the same `Request` in, `Response` out shape you see in [Cloudflare Workers](https://developers.cloudflare.com/workers/) and WinterCG runtimes. It is Hono-compatible, so Hono middleware drops in directly. The interesting part is composability. Individual Astro features can be composed as middleware: auth before your actions, a logging layer before everything, all in one file that owns the request lifecycle. `src/fetch.ts` is a reserved filename, so rename any existing file with that name. ## Route caching and CDN providers Route caching is now stable. You configure it with `routeRules` specifying `maxAge` and `swr` per route. Astro also ships experimental CDN cache providers for Netlify, Vercel, and Cloudflare, so cached responses live at the edge instead of in your origin's memory. For a docs site or a blog, this is straightforward. Set a `maxAge` on your content routes, a shorter one with `swr` for pages that update occasionally, and the framework handles invalidation. The CDN providers mean the cache survives deploys on those platforms, which is the part that was missing before. ## What breaks when upgrading to Astro 7 The [migration guide](https://docs.astro.build/en/guides/upgrade-to/v7/) lists the breaking changes. Here is what to expect: - **Rust compiler is stricter.** Unclosed tags error. No more HTML auto-correction. Fix your markup. - **Sätteri is the default Markdown processor.** If you depend on a specific remark or rehype plugin, install `@astrojs/markdown-remark` to keep the unified pipeline. - **`compressHTML` defaults to `jsx`** instead of `true`. Update your config if you relied on the old default. - **Experimental flags removed:** `rustCompiler`, `queuedRendering`, `advancedRouting`, `cache`, and `logger`. Their behaviors are now standard or gone. - **`@astrojs/db` is removed.** If you used the experimental database integration, you need a replacement. - **`src/fetch.ts` is a reserved filename.** Rename any existing file with that name. - **Container renderer imports changed** to the `/container-renderer` entrypoint. There are also two AI-oriented additions: a background dev server mode for agents, and JSON logging output. Both are aimed at tooling that drives Astro programmatically. ## Should you upgrade to Astro 7? Yes, and soon if your site is content-heavy. The build speedup alone is worth it. The migration is mostly mechanical: remove the dead experimental flags, check your Markdown plugins, fix any markup the new compiler rejects. Do the upgrade in an isolated [git worktree](/blog/git-worktrees-are-underrated) so your main checkout keeps building while you shake out the Sätteri differences and the stricter compiler. The broader story is the one I keep writing about. The JavaScript ecosystem is rewriting its foundations in native code. [TypeScript went to Go](/blog/typescript-7-is-here), [pnpm went to Rust](/blog/pnpm-v12-rust-rewrite), [Bun rewrote its runtime from Zig to Rust](/blog/bun-14-rust-rewrite), Vite swapped its bundler for Rolldown. And now Astro has rewritten its compiler, its Markdown pipeline, and its rendering engine in Rust, all in one release. The tools you use every day are getting faster underneath you, and the surface you write against is staying still. That is the best kind of progress. --- # Hermes Agent: A Beginner's Tutorial URL: https://morello.dev/blog/hermes-agent-for-beginners Published: July 18, 2026 Tags: ai, webdev, programming, opensource A beginner tutorial for Hermes Agent, the open-source AI agent that learns from experience. Install it, connect a model, and write your first skill. You've probably used an AI coding assistant by now. Copilot finishes your lines, Cursor rewrites your functions, Claude Code debugs your PRs. They're all variations on the same idea: an AI that lives inside your editor and helps you write code faster. Hermes Agent is something different. It's not a copilot. It's an [open-source autonomous agent](https://github.com/NousResearch/hermes-agent) built by [Nous Research](https://nousresearch.com) that learns from its own experience, creates reusable skills on the fly, and runs wherever you put it: a cheap VPS, a home server, or serverless infrastructure that costs nearly nothing when idle. You talk to it from Telegram while it works on a cloud VM you never SSH into. It's [not tied to your laptop](https://hermes-agent.nousresearch.com/docs). The project has 217,000 GitHub stars for a reason. Here's what it actually is, how to get started in under two minutes, and the first three things worth trying. ## What Hermes Agent actually is Hermes Agent is an autonomous agent with a [built-in learning loop](https://hermes-agent.nousresearch.com/docs). The loop works like this: every time you and Hermes accomplish something non-trivial, like fixing a tricky bug, configuring a deployment pipeline, or wiring up a new tool. It can save that procedure as a **skill**. A skill is just a markdown file with instructions. Next time a similar task comes up, Hermes loads the skill and follows the playbook instead of figuring it out from scratch. Skills get better with use. If a skill's instructions are outdated or missing a step, Hermes patches them on the spot. Over time, you accumulate a library of battle-tested procedures that compound. This is the thing that separates Hermes from a chatbot. It doesn't just answer questions, it gets more capable the longer it runs. Under the hood, it's an MIT-licensed Python project that talks to any LLM provider you point it at. [Over 20 are supported](https://hermes-agent.nousresearch.com/docs/integrations/providers/), including OpenRouter, Anthropic, OpenAI, Google, DeepSeek, and local models. The recommended setup is through [Nous Portal](https://portal.nousresearch.com), which gives you one OAuth login, one bill, and access to 300+ models plus built-in tools for web search, image generation, text-to-speech, and browser automation. But you can also bring your own API keys if you prefer. ## How to install it The installer handles everything. On macOS, Linux, WSL2, or Android via Termux: ```bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` On Windows, in PowerShell: ```powershell iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` The installer pulls in Python, Node.js, ripgrep, ffmpeg, and everything else Hermes needs. It clones the repo, sets up a virtual environment, and wires up the `hermes` command globally. The whole thing takes [under two minutes](https://hermes-agent.nousresearch.com/docs/getting-started/installation). After installing, reload your shell and run the fastest path to a working agent: ```bash hermes setup --portal ``` This opens a browser for OAuth. One login covers your model access plus all four Tool Gateway tools: web search, image generation, text-to-speech, and a cloud browser. No API keys to juggle. Once that's done, just type `hermes` and you're chatting. There's also a [native desktop app](https://hermes-agent.nousresearch.com/docs/getting-started/installation) for macOS, Linux, and Windows. Launch it with `hermes desktop`. But the CLI is where most people start. ## First thing to try: write a skill from experience Skills are where Hermes actually earns its keep. Here's the fastest way to see it work. Do something non-trivial with Hermes. Ask it to set up a project, configure a tool, or debug something that takes a few turns. When it succeeds, Hermes will offer to save the approach as a skill. Say yes. That skill now lives in `~/.hermes/skills/` as a markdown file with instructions, pitfalls, and a verification checklist. Next time you (or anyone else using that Hermes instance) hits the same kind of task, Hermes loads the skill and follows the playbook. The skill gets patched when it's wrong and sharpened when it's vague. After a few weeks of regular use, you stop repeating yourself. Hermes remembers how your projects are structured, which linter you prefer, and how to run your tests. The [Skills Hub](https://hermes-agent.nousresearch.com/docs/skills) has 88,000+ community skills you can install too, covering everything from Apple Notes management to LLM fine-tuning. Every installed skill becomes a slash command, like `/gif-search funny cats` or `/axolotl fine-tune Llama 3`. Hermes loads the skill's instructions on demand, so you're not burning tokens on stuff you're not using. ## Second thing: hook up a messaging platform Hermes is designed to be something you talk to from anywhere, not something you SSH into. After `hermes setup --portal`, configure a messaging gateway: ```bash hermes gateway setup ``` It walks you through connecting Telegram, Discord, Slack, WhatsApp, Signal, or any of the [20+ supported platforms](https://hermes-agent.nousresearch.com/docs). Once it's wired up, you message Hermes from your phone while it crunches through a long-running task on a server somewhere. It notifies you when it's done. This is the moment where Hermes stops feeling like a terminal tool and starts feeling like an agent you keep around. ## Third thing: spawn subagents for parallel work Hermes can delegate work to isolated subagents that run in parallel with their own context windows. This is useful when you have independent tasks that don't need to share state. Research a topic while a different subagent lints your code, or check three different APIs at once. From a conversation, just ask Hermes to "research X and Y in parallel" and it spawns the subagents. They report back when they're done. It's a simple model, but it's the kind of thing that makes multi-step workflows feel fast instead of sequential. ## What makes it different from Copilot, Cursor, and Claude Code Those tools are IDE copilots. They help you write code faster inside your editor. Hermes is an autonomous agent that lives wherever you deploy it. The table version: | | Copilot / Cursor / Claude Code | Hermes Agent | | --- | --- | --- | | **Where it lives** | Your editor | Anywhere (VPS, server, Modal, Daytona) | | **What it does** | Helps you write code | Does work autonomously, learns from it | | **How you talk to it** | IDE chat panel | CLI, Telegram, Discord, Slack, 20+ platforms | | **Memory** | Per-session context | Persistent, self-improving across sessions | | **Skills** | None | Creates, patches, and reuses procedures | | **Scheduling** | None | Built-in cron with any-platform delivery | | **License** | Proprietary | MIT | If you want faster autocomplete, use Copilot. If you want an agent that learns your stack, runs on your infrastructure, and gets more useful the longer it's around, Hermes is the thing to try. I've written before about [making sites readable by AI agents](/blog/configuring-my-site-for-ai-discoverability) and [making them usable by agents with WebMCP](/blog/webmcp-making-your-site-usable-by-ai-agents). Hermes is the other side of that equation: the agent that can actually consume and act on that content. And when every site needs its own copy of a model to run agents like this, the [Cross-Origin Storage API](/blog/cross-origin-storage-api) is the platform fix for the storage problem that creates. Install Hermes, teach it one thing, and watch it get better. --- # Cross-Origin Storage API: Stop Downloading Twice URL: https://morello.dev/blog/cross-origin-storage-api Published: July 17, 2026 Tags: webdev, javascript, ai, performance The Cross-Origin Storage API lets sites share one cached copy of a file (a JS library, font, or AI model) across origins by SHA-256 hash. Here's how it works. There was a time when loading React from a public CDN was a real performance trick. If enough sites pulled the same `react.production.min.js` off the same CDN URL, a first-time visitor to your site stood a good chance of already having it cached from some other site they'd visited. One download, reused everywhere. That was the whole pitch for shared CDNs. Browsers took it away on purpose. To stop sites from using the cache as a [cross-site tracking signal](https://developer.chrome.com/blog/http-cache-partitioning), they started partitioning the HTTP cache by top-level site. The attack they were closing: you can detect whether someone visited another site by timing whether a shared resource loads from cache. Chrome partitioned in version 86 in October 2020, Firefox in 85, Safari years before either. Now the same file at the same URL gets downloaded and stored separately for every site that uses it, and the old "just use a public CDN" advice [mostly stopped making sense](https://addyosmani.com/blog/double-keyed-caching/). The [Cross-Origin Storage (COS) API](https://wicg.github.io/cross-origin-storage/) is a WICG proposal to bring that shared cache back. Its trick is to change what the shared cache is keyed on. Rather than the URL, which is what leaked, COS keys the file by its cryptographic hash, and only lets you ask for a file whose exact bytes you already know. That closes the tracking hole the old shared cache opened. One copy of React, or a web font, or a big WebAssembly binary, shared across every origin that wants it. It's early. Nothing ships it natively yet. But the problem it solves is real and getting worse, so it's worth understanding now. ## What is the Cross-Origin Storage API? Cross-Origin Storage is a proposed browser API for storing and retrieving large files by their **content hash** rather than their URL, in a store that can be shared across origins with the user's device acting as the shared cache. A file is identified by its SHA-256 digest, so the same bytes fetched by two different sites from two different URLs map to a single entry on disk. The whole surface is one method on a new `navigator.crossOriginStorage` interface: ```js requestFileHandle(hash, options) ``` It returns a `Promise`, the same handle type as the [File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API), so once you have it you read the file exactly the way you already know how. There's no bespoke `store()`/`retrieve()` pair to learn; `requestFileHandle()` covers both directions, and the `create` flag decides which. The proposal comes from Googlers [Thomas Steiner](https://blog.tomayac.com/) and François Beaufort, together with [Christian Liebel](https://christianliebel.com/) of Thinktecture, and it's still at the incubation stage. The motivating cases are the files a lot of sites already share byte-for-byte: JavaScript libraries, web fonts, WebAssembly modules, game engines. A font served by Google Fonts gets requested by thousands of sites; store it once and any of them could read it locally instead of fetching it from a CDN on every visit. The problem gets extreme with AI models. The explainer's headline example is a single 8 GB model that two origins both need, which without COS means 16 GB downloaded and 16 GB sitting on disk for one file. The size list runs up from there: Gemma 2 at 1.35 GB, Llama-3.1-70B at 33 GB. That's the group feeling the most pain, but the mechanism is the same one that would let two sites share a copy of React. ## Why can't the Cache API or IndexedDB share files across origins? Because every existing storage mechanism is partitioned by origin, and every one of them is addressed by URL or key rather than by content. Those two facts are the whole problem. | Store | Addressed by | Shared across origins? | | --- | --- | --- | | Cache API | URL | No | | IndexedDB | key | No | | Origin Private File System | path | No | | **Cross-Origin Storage** | **content hash** | **Yes, within a declared scope** | The Cache API keys entries on the request URL, so the same copy of React served from `cdn-a.com/react.js` and `cdn-b.com/react.js` is two unrelated entries even if the bytes are identical, and each origin gets its own private Cache, so even the *same* URL is a separate entry per site. IndexedDB and the [Origin Private File System](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) are both walled inside a single origin by design. None of them can say "I don't care where this came from, I care that it hashes to `8f43…`". Content addressing is the one thing that makes cross-origin sharing safe to attempt, because you can only ask for a file whose exact bytes you already know. COS is explicitly *not* trying to replace any of them. It's a fourth thing for a narrow case: big files that lots of sites legitimately share. ## How does requestFileHandle() read and store files? You compute the file's SHA-256 hash, ask for a handle, and read it. Retrieving a file another origin already stored looks like this: ```js const hash = { algorithm: "SHA-256", value: "8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4", }; const handle = await navigator.crossOriginStorage.requestFileHandle(hash); const file = await handle.getFile(); ``` If the file isn't there, you fetch it the normal way and store it, passing `create: true` and the origins you're willing to share it with: ```js const handle = await navigator.crossOriginStorage.requestFileHandle(hash, { create: true, origins: ["https://example.com", "https://example.org"], }); const writable = await handle.createWritable(); await writable.write(fileBlob); await writable.close(); ``` The `hash` argument is a dictionary of `{ algorithm, value }`, where `value` is a 64-character lowercase hex digest. SHA-256 is the algorithm the spec builds around. On write, the browser verifies the bytes you hand it actually hash to the value you claimed and throws a `DataError` if they don't, so a stored file can never lie about its own identity. That verification is what lets a *different* origin trust the entry later without re-downloading it. The `origins` option is the access-control knob. Omit it and the file is scoped to your own site. Pass an explicit list and only those origins can see it. Pass `"*"` and you're offering it to the whole web, which is where the privacy design gets interesting. ## How does Cross-Origin Storage avoid becoming a supercookie? This is the question the whole design orbits, because a naive "do you have file X?" lookup is a cross-site tracking primitive. If a rare file were stored by exactly one obscure origin, any other site that could confirm its presence would learn you'd visited that origin. Content addressing alone doesn't save you here; the *answer* to the query is the leak. COS defends against it with layered gating rather than a permission prompt. **You can't enumerate.** There's no "list what's stored" call. As the spec puts it, developers "cannot enumerate the contents of Cross-Origin Storage or access a file without already knowing its hash." You can only probe for bytes you already have. **Global files need a crowd.** A `"*"`-scoped file's presence is only confirmable to an outside origin if its hash is on the **Public Hash List**, a registry a file joins only after clearing a k-anonymity-style popularity bar, appearing byte-identical across a minimum number of independent origins (the reference [public-hash-list](https://github.com/tomayac/public-hash-list) currently uses roughly 100). If a file is popular enough that "you have it" tells an attacker nothing about you specifically, sharing its presence is safe. If it's rare, the browser refuses to confirm. **The browser lies on purpose.** For files where the presence signal would be sensitive, the user agent may apply *GREASE'ing*: "occasionally responding as if a disclosable entry were absent, even though [gating] would otherwise permit disclosure," adding noise so a site can't distinguish a real miss from a privacy-motivated one. There's a nice pragmatic carve-out: the browser won't GREASE gigabyte-scale weights, since forcing a spurious multi-gigabyte re-download to protect a signal nobody's mining is a bad trade. The upshot is that a `NotFoundError` is deliberately ambiguous. It might mean the file isn't stored, or that you're out of scope, or that the hash isn't on the Public Hash List, or that the browser just decided not to tell you. The spec is explicit that it "does not prove the file is physically absent." One thing that surprised me, and corrects a lot of secondhand write-ups: **there's no per-file permission prompt, and no human-readable hash shown to the user.** Earlier tellings of this idea imagined a dialog where you'd confirm a file by some readable fingerprint. The current design dropped that. A handle returned from COS is already fully authorized, so calling `getFile()` or `createWritable()` "never triggers an additional permission prompt." User control lives in settings UI for inspecting and evicting stored files, not in an interstitial on every download. ## How does the declarative crossoriginstorage attribute work? Most sites shouldn't have to hash their own script tags, so the proposal also sketches a declarative path where the browser does the COS lookup for you. It leans on the `integrity` attribute you may already use for [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) (the same SHA-256 digest COS keys on), plus a new `crossoriginstorage` attribute: ```html ``` The same idea is floated for JavaScript import attributes and a `cross-origin-storage()` modifier in CSS `url()`. These aren't defined in the COS spec itself; each one has to land in its own host language's spec (HTML, TC39, CSS), so treat them as direction, not API. ## Is the Cross-Origin Storage API supported in browsers yet? No. Cross-Origin Storage is not implemented in any browser, there's no origin trial, and there's no flag to flip. Emscripten's own COS docs say it plainly: the API "has not yet shipped in any browser." It is not Baseline, and it isn't close. What exists is experimentation around the edges. There's a [Chrome extension that polyfills the API](https://github.com/web-ai-community/cross-origin-storage-extension) so libraries can develop against it, and the AI-in-the-browser crowd is already wiring in opt-in support: [Transformers.js](https://huggingface.co/blog/cross-origin-storage) gates it behind an `experimental_useCrossOriginStorage` flag, with WebLLM and wllama experimenting too. That's the natural first constituency: a 33 GB model you download once and reuse across every site that runs it is a far better story than downloading it per origin. On the standards side it's genuinely early. Mozilla opened a [standards-position issue](https://github.com/mozilla/standards-positions/issues/1427) on June 22, 2026, but hasn't taken a formal stance yet; I couldn't find a WebKit position or a W3C TAG review at all. So this is one browser's proposal with no committed implementers. A quick disambiguation while you're searching, because the names collide: Cross-Origin *Storage* is not the [Storage Access API](https://developer.mozilla.org/en-US/docs/Web/API/Storage_Access_API). That one is about letting embedded third-party content reach its own cookies. COS is about sharing large content-addressed files. Different problem, confusingly adjacent name. ## Should you use the Cross-Origin Storage API? Not enough to write code against it, but enough to track it. The waste it targets only compounds: every site now pays full freight for its own copy of the same shared libraries and fonts since the cache got partitioned, and in-browser AI keeps pushing file sizes into the gigabytes, where it really starts to hurt. A content-addressed cache shared across origins is the obvious fix, and the interesting engineering is entirely in making it *safe* rather than making it work. It also fits a pattern I keep noticing in these platform proposals, the same one behind [WebMCP](/blog/webmcp-making-your-site-usable-by-ai-agents) and the [HTTP QUERY method](/blog/the-new-http-query-method): the web already had the raw capability, and the standards work is mostly about giving it a name and a safety model. COS is that for deduplicated storage. There's a read-side version of this too, which I covered in [getting a site ready for AI readers](/blog/configuring-my-site-for-ai-discoverability): that post is about the content agents consume, this one is about the model weights they run. Whether it ships as-is, gets filed down by the standards process, or stalls, the shape of the answer looks right. I'd rather understand it now than the day a library flips it on by default. --- # SolidJS 2.0 Async Data: A Deep Dive for React Devs URL: https://morello.dev/blog/solidjs-2-async-data Published: July 16, 2026 Tags: solidjs, react, javascript, webdev How SolidJS 2.0 handles async data: promises in the reactive graph, the Loading boundary, isPending, and generator actions, explained for React developers. In [my first look at SolidJS 2.0](/blog/solidjs-2-react-developers-first-look) I said first-class async was the real story, then barely opened the box. This post opens it. Here's the short version. In Solid 2.0 a promise is just another value the reactive graph knows how to wait for. A computation can return one, anything that reads it suspends until it resolves, and a `` boundary shows a fallback only until the first real value lands. There's no `createResource`, no manual `await` in your components, and no re-render. If you write React, this is the part that behaves least like what you're used to, so it's worth slowing down on. Everything below is from the `2.0.0-beta.18` line (`npm install solid-js@next`). It's a beta, and some of these names will still move before stable, so I've flagged the parts that are rougher. ## How does async data fetching work in Solid 2.0? A derived computation can return a promise, and the reactive graph treats the pending state as "not ready" until it settles. `createResource` is gone; a plain `createMemo` that returns a promise does the job, as the [async RFC](https://github.com/solidjs/solid/blob/next/documentation/solid-2.0/05-async-data.md) puts it: async is "a first-class capability of computations." ```jsx // Solid 1.x const [user] = createResource(userId, fetchUser); // Solid 2.0: the memo returns a promise, the graph waits on it const user = createMemo(() => fetchUser(userId())); ``` Read `user()` anywhere and you get the resolved value. While the promise is in flight the read throws a `NotReadyError` internally, which propagates up the graph to the nearest boundary rather than crashing. You never catch it yourself; it's the mechanism that lets `` and error boundaries work without a dedicated suspense primitive. When `userId()` changes, the memo re-runs, returns a new promise, and the graph waits again. Same accessor, no dependency array, no effect wired up to refetch. If you need the value imperatively outside a reactive scope (a route loader, an event handler), `resolve` hands you a real promise that settles once the expression is ready: ```js const currentUser = await resolve(() => user()); ``` One correction to a thing you'll see repeated: **there is no `createAsync` in core 2.0.** `createAsync` is a `@solidjs/router` API, and its 2.0 shape isn't settled in the sources I could find, so don't reach for it as the core data primitive. The core primitive is the async `createMemo` above. ## What replaced ``? The `` boundary `` is the direct replacement for 1.x's ``, and the semantics are the reason the release was titled ["The `` is Over."](https://github.com/solidjs/solid/releases/tag/v2.0.0-beta.0) `` covers **initial readiness only**. It shows its fallback while the subtree has nothing to render yet, and once real content is on screen it gets out of the way. ```jsx }> ``` This is the behavior React's Suspense makes you fight for. In React, when a value a Suspense boundary depends on changes, the boundary can rip the rendered content down and flash the fallback again unless you wrap the update in [`useTransition`](https://react.dev/reference/react/useTransition). That's also why router-driven navigations, which wrap their updates in transitions by default, keep the stale content visible instead and need a [`key`](https://react.dev/reference/react/Suspense#resetting-suspense-boundaries-on-navigation) to reset the boundary. Solid inverts the default: after the first paint, a refetch holds the old content on screen. It won't kick you back to the spinner. When you do want the fallback to reappear on a specific change (say the whole record is being swapped, not refreshed), the new `on` prop opts into that: ```jsx }> ``` Now a change to `userId()` while data is pending re-shows the fallback; other pending work doesn't. ## How do you coordinate several boundaries? `` `` replaces 1.x's `` and controls the order in which sibling `` boundaries reveal their content. It takes an `order` prop of `"sequential"` (the default), `"together"`, or `"natural"`: - `sequential` reveals boundaries in DOM order; later ones stay on their fallbacks until every earlier one has resolved. - `together` holds every fallback until the whole group is ready, then reveals at once. - `natural` lets each boundary reveal as soon as its own data lands. There's also a `collapsed` boolean, consulted only under `order="sequential"`, that renders a single frontier fallback instead of one per boundary. The [control-flow RFC](https://github.com/solidjs/solid/blob/next/documentation/solid-2.0/03-control-flow.md) is the only source documenting the fine-grained nesting rules here, so treat the exact `` semantics as provisional until stable. ## How do you show a refresh without a spinner? `isPending` `isPending` reports that a change to a specific read is in flight, so you can show a subtle indicator while the stale content stays put. It takes a thunk, and it actually performs the read, so where you place it matters. ```jsx const refreshing = () => isPending(() => user()); }> ; ``` First load hits `` and shows the spinner. A later refetch keeps `` visible and flips `refreshing()` to `true`, so you render a thin bar instead of tearing the page down. Stale-while-revalidate comes built into the primitive, so you don't assemble it yourself. The gotcha that will catch you: **a bare `refresh()` reads as not pending.** Re-asking the same question (a poll, a manual refresh, a confirming refetch after a mutation) is treated as silent by design. If you want that reload to register as pending, you declare it with `affects`: ```js // Silent: isPending stays false, no refresh UI refresh(user); // Declared: now the refresh reads as pending affects(user); refresh(user); ``` `affects` is one of the newer, rougher corners of the API (it lives in a single RFC family and is likely to move), but the underlying rule is worth knowing now: pending is about a *changed input*, not about work happening. There's also a `latest(fn)` helper that peeks at the in-flight value during a transition and falls back to stale if the next value isn't ready, for when you want to show the incoming id before its data lands. ## Where do mutations live now? `action` and optimistic updates Solid 2.0 gives writes a home in core with `action`, and the shape is unusual: `action` wraps a **generator**. Each `yield` is a point where the action awaits, which lets the reactive system track an optimistic value until the real one lands. ```js const [todos, setTodos] = createOptimisticStore(() => api.getTodos(), []); const addTodo = action(function* (todo) { setTodos((t) => { t.push(todo); // optimistic: show it immediately }); yield api.addTodo(todo); // await the server refresh(todos); // reconcile with the source of truth }); ``` `createOptimistic` has the same surface as `createSignal`, but its writes are optimistic: they can be overridden during a transition and revert when the transition completes. `createOptimisticStore` is the store version. The RFCs document it as `createOptimisticStore(fnOrValue, seed, options?)` while the [migration guide](https://github.com/solidjs/solid/blob/next/documentation/solid-2.0/MIGRATION.md) shows a single-object form, so the exact arity is still settling; the two-argument derived form above is the one that's fully specified. `refresh(x)` asks a derived signal or store to recompute. It's imperative revalidation, not a piece of UI state, which is why the `affects` dance above exists for the cases where you want a refresh to be visible. An async-generator form of `action` also works, where a bare `yield;` resumes the action in the same transition context after an `await`. If you've used React 19's [Actions and `useOptimistic`](https://react.dev/reference/react/useOptimistic), the intent is familiar. The difference is that Solid folds the optimistic write, the server call, and the revalidation into one generator instead of splitting them across a hook and an action. ## Why is batching deterministic now? Solid 2.0 batches updates on a microtask by default, so a setter queues the write and reads don't reflect it until the batch flushes. `batch()` is removed; `flush()` is how you apply pending updates synchronously when you need the result right away. ```js const [count, setCount] = createSignal(0); setCount(1); count(); // still 0: queued on the microtask flush(); count(); // 1 ``` I mentioned this in the first post as a gotcha; the reason it matters here is that it's what makes async reliable. Running the tracking (compute) half of every effect before any side-effecting half gives the graph a complete dependency picture before anything runs, which is exactly what `` and error boundaries need to decide what's ready. `flush(fn)` also takes a callback and drains the writes inside it before returning, preserving the return value. Coming from React's synchronous batching it's an adjustment, but the model is predictable once async is threaded through everything. ## What changed for transitions? They're built in This one's aimed squarely at React developers: **`startTransition` and `useTransition` are gone.** Solid 2.0 treats transitions as a core scheduling concept, and multiple can be in flight at once. Pending UI is expressed through `` and `isPending` rather than by wrapping updates. You don't opt in per call site; the framework does the bookkeeping. Here's the async surface mapped against React 19: | Task | React 19 | Solid 2.0 | | --- | --- | --- | | Fetch data | `use()` + Suspense, or a data library | `createMemo` returning a promise | | Loading UI | `` (can re-trigger) | `` (initial readiness only) | | Refresh without a flash | `useTransition` + `isPending`, opt-in per call | `isPending(fn)`, observe any read | | Optimistic update | `useOptimistic` | `createOptimistic` / `createOptimisticStore` | | Mutations | Actions / form actions | `action(function*)` + `refresh` | | Batching | automatic sync batching | microtask batching + `flush()` | The pattern across that table: React exposes each capability as a hook you opt into, while Solid pushes the distinction between "nothing to show" and "refreshing what's shown" down into the primitives, so you get the good behavior by default. That reactivity model is the same shift toward [signals happening across the whole ecosystem](/blog/five-things-you-might-not-know-about-javascript). ## Should you build on this yet? Not in production. It's `beta.18`, the same early-but-real stage [pnpm v12's Rust rewrite](/blog/pnpm-v12-rust-rewrite) is in. The async core (async `createMemo`, ``, microtask batching with `flush`, generator `action`s) is stable enough to learn on, but the edges (`affects`, `createOptimisticStore`'s arity, ``'s nesting rules) are still moving. The announcement is worth reading in full in the [beta.0 discussion](https://github.com/solidjs/solid/discussions/2596). What I'd actually do, and what I'm doing: build a small data-heavy screen with `solid-js@next`, wire up a `createMemo` that fetches, drop a `` around it, and trigger a refetch. Watch the old content stay on screen while the new content loads. That single behavior is the clearest argument for why fine-grained reactivity was worth following all the way to async. --- # pnpm v12 Is Being Rewritten in Rust URL: https://morello.dev/blog/pnpm-v12-rust-rewrite Published: July 15, 2026 Tags: javascript, webdev, rust, node pnpm v12 is a 1:1 rewrite of its install engine in Rust, with no Node.js launcher and warm installs up to 30x faster. Still alpha, but you can try it today. Every package manager has a moment where you just wait. You add one dependency, or you pull a branch and run install, and there's a beat where the terminal sits there doing file I/O while you tab away. [pnpm](https://pnpm.io) already made that beat shorter than most, staying ahead of npm and Yarn on most install scenarios thanks to its content-addressable store and hard-linked `node_modules`. But it was still a Node.js program, paying the Node startup cost and pushing tens of thousands of file operations through a [single JavaScript runtime](/blog/five-things-you-might-not-know-about-javascript). That is what changes in pnpm v12: it rewrites the install engine, the part that fetches and links packages, in [Rust](https://www.rust-lang.org), and leaves the CLI, lockfile, and `node_modules` layout untouched. It's the same move [TypeScript just made with Go](/blog/typescript-7-is-here), aimed at a different bottleneck. And like that one, the interesting detail is how little is supposed to change for you. ## What pnpm v12 actually is pnpm v12 is the same pnpm you already use, ported to Rust. pnpm has always been TypeScript running on Node.js, and the Rust work lives under the codename **`pacquet`**, whose repo describes it in one line: "The official pnpm rewrite in Rust." The key word there is *rewrite*, not *reimagining*. It's a port. The flags, defaults, error codes, lockfile format, and `node_modules` layout are all meant to match pnpm exactly. Zoltan Kochan, pnpm's creator, [put it plainly](https://github.com/orgs/pnpm/discussions/11292) when the v12 plan came together: "v12 should behave the same as v11, the biggest change will be the full rewrite to Rust." That's the frame to hold onto. This is a performance release wearing a major version number, not a redesign of how pnpm works. `pacquet` started as a separate experimental repo and [moved into the main pnpm monorepo](https://x.com/zkochan/status/2054966567692099943) in May 2026, so the TypeScript and Rust versions now evolve side by side. The eventual goal is for the Rust engine to be what you run. ## Why did pnpm choose Rust? The motivation isn't abstract. Two things were capping how fast a Node-based pnpm could go. The first is startup cost. Every invocation booted a Node.js runtime before it did any work. pnpm v12 ships as native per-platform binaries with [no Node.js launcher](https://pnpm.io/blog/releases/11.10), so a command pays no runtime bootstrap cost at all. On a fast, cached install where the actual work is milliseconds, that bootstrap was a real fraction of the wall-clock time. The second is the file I/O itself. A cold install on a large monorepo is really a lot of filesystem operations: fetching tarballs, unpacking them into the store, and hard-linking thousands of files into place. Running all of that through a single JavaScript thread leaves throughput on the table. Rust handles that fan-out natively, without the runtime sitting in the middle of every syscall. There's a softer reason too, and it's the same one that's driving the broader trend: the tooling to do this kind of rewrite has gotten dramatically better, and AI-assisted porting made a 1:1 translation of a large codebase far more tractable than it would have been a few years ago. ## How much faster is pnpm v12? pnpm publishes a [benchmark suite](https://pnpm.io/benchmarks) comparing v11 against the Rust engine directly. As of this writing (July 2026), here's what it shows: | Scenario | pnpm v11 | pnpm (Rust) | | --------------------------------- | -------: | ----------: | | Everything warm (repeat install) | 381 ms | 12 ms | | node_modules present, no cache | 460 ms | 40 ms | | Clean install (nothing cached) | 6.5 s | 2.2 s | The warm case is everything already in place, so the install just re-verifies the tree, which is what happens on every CI job. That drops from 381ms to 12ms, roughly a 30x difference, and it's the number you'll feel most, because it runs constantly. The colder cases improve less dramatically but still meaningfully: a fully clean install with nothing cached goes from 6.5s to 2.2s, about 3x. Treat these as pnpm's own numbers on pnpm's own hardware, and expect your mileage to vary with disk speed and project size. But the direction is not subtle, and the team's claim is that the Rust engine is faster than v11 in every scenario they measured. ## It's already rolling out, incrementally Here's the part that's easy to get wrong: pnpm v12 isn't a big-bang release. The Rust engine has been landing piece by piece through the v11 line, opt-in first. - **pnpm 11.2** let you opt into pacquet as the install backend for materialization only. Rust did the fetch and link, while pnpm still resolved dependencies and wrote the lockfile. - **pnpm 11.7** went further: a standard install could be [delegated to pacquet end-to-end](https://pnpm.io/blog/releases/11.7), with resolution, lockfile write, and linking all in a single Rust pass. - **pnpm 11.10** added `pnpm self-update next-12`, so you can pull the v12 line and try the Rust binary directly. In v12, the Rust engine for fetching and linking becomes the default rather than an opt-in. The [official roadmap](https://github.com/pnpm/pnpm/issues/11633) then works outward from there: first the headless frozen-lockfile installer, then full dependency resolution (`add`, `update`, `remove`, catalogs, overrides, peers), and finally the remaining commands like `run`, `store`, and `publish`. So even after v12 ships, parts of pnpm will still run through the TypeScript implementation for a while. The lockfile is what lets the two halves cooperate. ## What changes for you? The honest answer is: not much, on purpose. Same CLI, same lockfile, same `node_modules`. The whole point of a 1:1 port is that your muscle memory and your CI scripts keep working. The breaking changes slated for v12 are [deliberately small](https://github.com/orgs/pnpm/discussions/11292): removing some old migration code for git-hosted tarballs, rejecting unscoped auth settings, and deprecating the `$` syntax in `overrides` in favor of catalogs. If none of those describe your setup, a v12 upgrade should be close to a no-op. ### Will the upgrade break your CI? Probably not, but the one thing worth checking is feature parity during the transition. Because the Rust engine is being ported incrementally, some newer or niche capabilities may lag the TypeScript version at any given moment. If your install depends on something specific (a particular linker mode, lockfile-verification settings, or `pnpm deploy`), test the bump in an isolated [git worktree](/blog/git-worktrees-are-underrated) before you flip it on in CI, the same sandbox I used while [exploring SolidJS 2.0's async data model](/blog/solidjs-2-async-data). This is moving quickly, so the right list of gaps is whatever the roadmap says on the day you upgrade. ## The bigger picture pnpm is not doing this alone. The last few years have quietly rebuilt the JavaScript toolchain's foundations in native languages: [Biome](https://biomejs.dev) for linting and formatting, [Oxc](https://oxc.rs) for parsing and transforming, [Rolldown](https://rolldown.rs) and Turbopack for bundling, and now [Astro 7](/blog/astro-7) with its Rust compiler and Sätteri markdown pipeline. TypeScript's compiler went to Go. Deno is Rust from the ground up. (Bun was the odd one out for a while, written in Zig, not Rust. That's no longer true: it's now rewriting itself from Zig to Rust too, an AI-assisted port I wrote up separately in [Bun's Rust rewrite](/blog/bun-14-rust-rewrite).) There's a competitive angle underneath it, too. Bun and Deno both ship as single fast binaries with package management built in, and a chunk of their appeal is exactly that no-runtime-bootstrap speed. A native-binary pnpm with no Node launcher closes that gap while keeping the thing that makes pnpm pnpm: the strict, content-addressable store and the non-flat `node_modules` that catches phantom dependencies. ## Should you upgrade to pnpm v12 yet? If you're waiting for a stable release: keep waiting, but not for long. As of mid-July 2026, v12 is in alpha. The latest tag is `v12.0.0-alpha.12`, and the stable line is still v11.13.0. This is not something to point production CI at today. But if you want to feel the difference now, you already can. Run `pnpm self-update next-12` on a branch, or opt into the pacquet backend on your current v11, and watch a warm install that used to take a beat return before you've finished reading the command. The type of release this is, where the language underneath gets an order of magnitude faster while everything on top stays exactly where you left it, is a rare and good one. pnpm was already the fast package manager. It's about to stop being one you ever wait on. --- # Make Your Site Usable by AI Agents with WebMCP URL: https://morello.dev/blog/webmcp-making-your-site-usable-by-ai-agents Published: July 13, 2026 Tags: ai, webdev, javascript, mcp AI agents struggle to act on websites. WebMCP lets your page expose typed tools they can call directly. What it is, how to try it in Chrome, and the caveats. A while back I wrote about [configuring this site for AI discoverability](/blog/configuring-my-site-for-ai-discoverability): raw Markdown mirrors, `llms.txt`, an AI stance in `robots.txt`. All of that is about making a site *readable*. An agent fetches your prose cheaply, summarizes it, and moves on. The agent reads, but it can't do anything on your page. WebMCP is the other half. Instead of handing an agent your text to read, you hand it a set of typed functions to call: search your posts, filter a product list, submit a form. The page declares what it can do, and an AI agent running in the browser invokes those actions directly. It's early and experimental, Chromium-only behind a flag, and the spec changes almost weekly. But it's the first serious attempt at answering "how do I make my site *usable* by agents, not just legible to them," so it's worth understanding now. ## What is WebMCP? WebMCP is a proposed web API that lets a page expose structured **tools** to an AI agent: JavaScript functions, each with a natural-language description and a JSON Schema for its parameters, that an in-browser agent can discover and call. In [Chrome's own words](https://developer.chrome.com/docs/ai/webmcp), it's "a proposed web standard to help you build and expose structured tools for AI agents." The name is the giveaway. It's [Model Context Protocol](https://modelcontextprotocol.io) adapted for the browser. A normal MCP server is a separate backend process that advertises tools to a model over a protocol. WebMCP takes that same tool model, name, description, input schema, and a result payload, and moves it into the page itself. Your page becomes, in effect, a client-side MCP server. The tools are defined *and* executed in your own JavaScript context, reusing the session the user is already logged into. No extra backend, no separate auth handshake. ## Why not just let the agent read the page? Because reading a page to *act* on it is expensive and fragile. Today an agent that wants to click a button downloads your DOM, maybe screenshots the page, infers which element is the "add to cart" control, and computes where to click. That burns tokens, adds latency, and breaks the moment a layout shifts or an ad loads late. You're asking a model to reverse-engineer an interface that was built for eyes and a mouse. WebMCP replaces that guesswork with an explicit contract. The page says "here is a `search_posts` tool, it takes a `tag`, here's what it returns," and the agent calls it like a function. It's the difference between scraping a form and being handed its API. It also fills a gap the discoverability tools don't: | Mechanism | What it gives an agent | Direction | | --- | --- | --- | | `llms.txt` / Markdown mirrors | Your content, cheaply | Read-only | | Remote MCP server | Tools, via a separate backend + its own auth | Actionable, off-page | | **WebMCP** | Tools, in the page, on the user's session | Actionable, in-page | `llms.txt` tells an agent what you've written. A remote MCP server exposes actions but lives on its own infrastructure with its own OAuth story. WebMCP is the in-page option: actions that run in the tab the user already has open, with the auth they already have. ## What does the API look like? You register tools on `document.modelContext`. Each call defines one tool with four keys: a `name`, a `description` the agent reads to decide when to use it, an `inputSchema` (JSON Schema) for the arguments, and an async `execute` function that does the work. ```js await document.modelContext.registerTool({ name: "search_posts", description: "Search blog posts by tag. Returns matching titles and URLs.", inputSchema: { type: "object", properties: { tag: { type: "string", description: "A single lowercase tag, e.g. 'webdev'" }, }, required: ["tag"], }, execute: async ({ tag }) => { const posts = await findPostsByTag(tag); return `Found ${posts.length} posts tagged ${tag}.`; }, }); ``` That example returns a plain string, which the [imperative API docs](https://developer.chrome.com/docs/ai/webmcp/imperative-api) accept. The spec actually types the return as `any`, so a string is fine, but if you want to match what a regular MCP tool sends back you'd return a [`{ content: [{ type: "text", text: "…" }] }` object](https://modelcontextprotocol.io/specification/2025-06-18/server/tools). WebMCP mirrors that convention without enforcing it. Tools are registered one at a time, and a `toolchange` event fires whenever the set changes, so other frames can react when you add or remove a tool. One trap if you follow older tutorials: the entry point used to be `navigator.modelContext`. That's **deprecated as of Chrome 150** in favor of `document.modelContext`. Write the new one; a lot of early write-ups still show the old surface. There's also a declarative flavor where you annotate existing `
` elements and Chrome exposes them as tools automatically. Chrome documents it, but the spec itself hasn't defined it yet, its section is literally marked as a TODO pointing back to the explainer. Treat that one as a preview of a preview. ## How do you try it today? In Chrome, behind an origin trial. Per the [Chromium "Intent to Experiment"](https://groups.google.com/a/chromium.org/g/blink-dev/c/gmYffo5WOE8/m/OJxuQRP3AAAJ), WebMCP runs as an origin trial from **Chrome 149 through 156**, on desktop first, plus Android and WebView. You can [sign up for the trial](https://developer.chrome.com/blog/ai-webmcp-origin-trial) to enable it for real users, or flip it on locally at `chrome://flags/#enable-webmcp-testing`. The cross-browser picture is the honest catch. In the same filing, Mozilla (Gecko) and WebKit are both recorded as **"No signal."** And despite Microsoft co-authoring the spec, there's no shipping Edge support I could find. So right now this is a Chromium-only experiment. Don't build anything on it expecting Firefox or Safari to follow soon; they haven't said they will. ## Is WebMCP an actual standard? Not yet, and it's worth being precise about that. WebMCP is incubated in the [W3C Web Machine Learning Community Group](https://webmachinelearning.github.io/webmcp/), and the current draft is dated **July 10, 2026**. A Community Group Report is explicitly *not* a W3C Standard and not on the Recommendation track. It's a proposal with momentum, edited by engineers from Google and Microsoft, but it can still change shape or stall. The origin story is a good reminder of how early this is. WebMCP grew out of **MCP-B**, a browser extension built by [Alex Nahas](https://github.com/MiguelsPizza). His insight was that the browser already solves the hard part of agent auth, you're logged in, so run MCP *inside* the browser and inherit that session instead of standing up a server and an OAuth flow for every tool. That extension work converged into the W3C proposal and got renamed WebMCP. He built MCP-B in January 2025, and the draft is dated July 2026, so we're about eighteen months out from a browser extension becoming a draft spec. Worth keeping in mind before you plan a roadmap around it. ## What about security? This is the part I'd read before shipping anything, because handing a language model a set of callable actions on your users' authenticated sessions is exactly as dangerous as it sounds. The [security model](https://developer.chrome.com/docs/ai/webmcp/secure-tools) gives you a few knobs: - **`exposedTo`** allowlists which origins can see a tool. Tools aren't exposed cross-origin by default. - **`untrustedContentHint`** flags a tool's output as untrusted (user-generated or external) so the agent treats it with more suspicion, a prompt-injection signal. - **`readOnlyHint`** marks a tool that doesn't change state, so an agent can skip a confirmation prompt. The docs still warn that even a read can leak information, so only expose to origins you trust. - **`requestUserInteraction()`** lets a tool pause mid-execution to ask the user to confirm. But the docs are refreshingly blunt about the ceiling here: prompt injection is not solved. They acknowledge that repeatable prompt-injection attacks have succeeded against state-of-the-art models, and that because models are probabilistic, safety can't be guaranteed inside the model itself. Origin gating, the hints, and human confirmation are defense in depth, not a guarantee. If you expose a destructive action as a tool, assume it can be tricked into firing. ## Should you add it to your site? Not to production, not yet. It's Chromium-only, gated behind an origin trial that expires at Chrome 156, the declarative API isn't specified, no other engine has signaled support, and the spec is moving week to week. Anything you build today you'll be rewriting. But it's worth prototyping, and worth understanding, because the shape of it is right. My [discoverability post](/blog/configuring-my-site-for-ai-discoverability) was about the read side: serve agents a version of your content they can consume cheaply. WebMCP is the write side. It's the first proposal that treats agent interaction as an explicit, typed contract the page controls, rather than something agents reverse-engineer from your DOM. Whether it ships as-is, gets filed down by the standards process, or stalls, the shape of the answer looks right. Same instinct that made [the QUERY method](/blog/the-new-http-query-method) and [Cross-Origin Storage](/blog/cross-origin-storage-api) satisfying: the platform finally growing a real name for something we'd been faking. Maybe it ships as WebMCP, maybe the standards process files it down into something else. Either way, sites are going to start declaring what agents can do, not just what they can read. I'd rather figure that out while it's a flag in Chrome than after it's a default everywhere. --- # Build-Time OG Images with Satori and Astro URL: https://morello.dev/blog/generating-og-images-with-satori-and-astro Published: July 12, 2026 Tags: astro, webdev, javascript, performance How this Astro site renders social cards at build time with Satori and resvg-js, and the design and performance choices that keep it fast. Every page on this site ships with its own [Open Graph](https://ogp.me/) image, the little card that shows up when you paste a link into Slack, X, or iMessage. None of them are drawn by hand. Each one is generated at build time by two tools working in sequence: [Satori](https://github.com/vercel/satori) turns a chunk of JSX styled with a CSS subset into an SVG, and [resvg-js](https://github.com/yisibl/resvg-js) rasterizes that SVG into a PNG. The result is a static `.png` for the homepage, the blog index, every post, and every tag, all baked into `dist/` and served straight from the edge with zero work at request time. I rebuilt this whole pipeline recently, and it turned into a nice case study in doing something simple well. Below is how it works, why it's built the way it is, and the handful of performance decisions that let it run for every page on every build without slowing anything down. ## What actually generates the images The pipeline is two libraries, each doing one job. **Satori**, from Vercel, is described in its own repo as a library "to convert HTML and CSS to SVG." You hand it an object tree that looks like JSX and a set of fonts, and it lays everything out with a Flexbox engine and returns an SVG string. It's the same engine behind [Vercel's OG image generation](https://vercel.com/docs/og-image-generation), so it's built for exactly this. **resvg-js**, maintained by [yisibl](https://github.com/yisibl), is "a high-performance SVG renderer and toolkit, powered by Rust based resvg." Satori gives you vector output, but social platforms want a raster image, so resvg-js takes the SVG and renders a PNG. It's a thin Node binding over the Rust [resvg](https://github.com/linebender/resvg) library, which is fast and has no runtime dependencies. The whole thing is about ten lines once the layout exists: ```ts const svg = await satori(layout, { width: OG_WIDTH, height: OG_HEIGHT, fonts: [ { name: "JetBrains Mono", data: fonts.regular, weight: 400, style: "normal" }, { name: "JetBrains Mono", data: fonts.bold, weight: 700, style: "normal" }, ], }); const resvg = new Resvg(svg, { font: { loadSystemFonts: false }, fitTo: { mode: "width", value: OG_WIDTH }, }); return resvg.render().asPng(); ``` That `loadSystemFonts: false` line is doing more than it looks like. I'll come back to it. ## The design: one card, deliberately plain There is exactly one card template. Everything on the site, from a post to the tags hub, renders through the same function with different props. That's a design decision, not laziness. A single template means every share preview looks like it came from the same place, and there's only one layout to keep correct. The layout is built from a few primitives. A `morello.dev` wordmark sits top-left in the accent color, with an optional eyebrow (`blog`, `tags`) on the right. The title dominates the middle: 60px, bold, left-aligned, vertically centered, capped at a `maxWidth` so long titles wrap instead of running to the edge. A byline anchors the bottom, either the post author or a page subtitle, introduced by a short accent-colored tick. That's the whole thing. The type is [JetBrains Mono](https://www.jetbrains.com/lp/mono/) throughout, which matches the monospace-first identity of [the rest of the site](/blog/the-new-website). The color scheme is Catppuccin Mocha: a near-black `#11111b` background, `#cdd6f4` text, and a `#89b4fa` blue accent. Here's the part people find surprising. The site itself has [16 themes you can switch between](/blog/the-new-website), but the OG cards are locked to Catppuccin Mocha and don't follow the visitor's choice. That's on purpose. A share card is a static artifact generated once at build time. The person who sees it on X has no relationship to the `localStorage` theme of whoever pasted the link. Making the card theme-aware would mean generating 16 variants of every image to serve one that nobody specifically asked for. So the OG palette lives in its own tiny `colors.ts` file, mirroring the default theme's tokens, completely detached from the runtime theming system. ## Why generate at build time instead of on demand Because there's no reason to pay for it twice. A lot of OG setups render the image in a serverless function on the first request and cache it. That's the right call when your content is dynamic or unbounded. Mine isn't. It's a static site with a known, finite set of pages, so I generate every card during `astro build` through `getStaticPaths`: ```ts export const getStaticPaths: GetStaticPaths = async () => { const [posts, tags] = await Promise.all([getCollection("blog"), getAllTags()]); // ...one entry per static page, post, and tag }; ``` Astro walks that list and writes a real PNG for each path into `dist/`. At request time there's no function to invoke and no rendering step. [Cloudflare](https://www.cloudflare.com/) serves the file from its asset cache like any other static file. The cost is paid once, on my machine or in CI, and never again. For a site that already ships as static assets, running a serverless renderer for images would be the odd one out. Paginated pages get a small optimization on top of this. `/blog/2` and beyond don't need their own card, so they reuse the page-1 image (`/og/blog.png`) instead of generating a near-identical copy per page. Same for tag pagination. That's fewer images to render and fewer bytes in `dist/`, for previews that would look identical either way. ## The performance decisions that matter Generating a few dozen images per build sounds cheap, and it is, but the naive version was slow enough on my machine to be annoying. A few changes made it genuinely fast. **Skip the system-font scan.** This is the big one. By default, resvg-js scans the operating system's installed fonts so it can render `` elements. Its own type definitions say of `loadSystemFonts`: "Default: true, if set to false, it will be faster." On a developer machine with a large font library (hello, macOS), that scan runs on every single `Resvg` construction and dominates the render time. The trick is that we never need it. Satori, by default, "renders the text as `` in SVG, instead of ``" and "embeds the font path data as inlined information." Every glyph is already a vector outline by the time resvg-js sees the SVG, so there are no fonts left to resolve. Setting `loadSystemFonts: false` was safe and cut per-image render time by roughly 20x locally. In CI, where the container has almost no fonts installed, the scan was cheap anyway, so this is pure upside. **Load fonts from the build, not a CDN.** Satori needs the actual font bytes passed in. Early on I fetched JetBrains Mono from a CDN at build time, which meant the build made a network call and broke offline. Now the fonts come from Astro's [fonts API](https://docs.astro.build/en/reference/experimental-flags/fonts/) via `astro:assets`, read straight off disk. The build makes no network call for them and pulls in nothing external. **Load the font once.** The font bytes are the same for every image, so re-reading them per card is wasted work. A small memoized loader reads each weight once and hands the same buffers to every render for the rest of the build: ```ts export const loadFonts = (() => { let cache: Promise<{ regular: ArrayBuffer; bold: ArrayBuffer }> | undefined; return () => { cache ??= Promise.all([loadWeight("400"), loadWeight("700")]).then( ([regular, bold]) => ({ regular, bold }), ); return cache; }; })(); ``` **Use woff, not woff2.** This one is a gotcha rather than an optimization. Satori's README is blunt about it: "Satori currently supports three font formats: TTF, OTF and WOFF. Note that WOFF2 is not supported at the moment." So the OG font is a plain `.woff` at weights 400 and 700, a separate entry from the variable `.woff2` the site itself uses. Feed Satori a woff2 and it fails; the format that's best for the browser is the one Satori can't read. **Drop decorative texture.** The card used to have a faint dot-grid background. It looked fine, but every dot is geometry Satori has to lay out and resvg-js has to rasterize, multiplied across every image. I removed it in favor of a flat background. The card looks cleaner and each render does less work. ## OG best practices worth stealing A few things I'd carry to any OG setup, whatever tools you use: - **Size the card 1200x630.** [Facebook's sharing docs](https://developers.facebook.com/docs/sharing/webmasters/images/) recommend "images that are at least 1200 x 630 pixels for the best display on high resolution devices" and an aspect ratio "as close to 1.91:1 as possible." Worth noting: the [Open Graph protocol itself](https://ogp.me/) never specifies a size, so this number comes from the platforms, not the spec. 1200x630 is the safe cross-platform default, and it's what `OG_WIDTH` and `OG_HEIGHT` are set to. - **Let the title wrap gracefully.** Satori supports `textWrap: balance`, which evens out line lengths so a two-line title doesn't leave one word stranded. It does not honor `text-wrap: pretty`, so `balance` is the one to reach for. - **Design within Satori's CSS subset.** Satori lays out with Flexbox, and `display: grid` isn't supported. If you're used to reaching for grid, you'll rewrite it as nested flex containers. Knowing this up front saves you from debugging a layout that silently doesn't apply. - **Always set `og:image:alt`.** The card is an image like any other, and it deserves alt text for the same accessibility reasons. Astro's OG plumbing handles the meta tags, but the alt text is content you have to write. - **Keep the social palette static.** Even if your site has themes, pin the cards to one look. A share preview is generated once and seen by strangers; it should be consistent, not personalized. ## The takeaway The pipeline that generates every social card on this site is two libraries and a couple hundred lines: Satori for HTML and CSS to SVG, resvg-js for SVG to PNG, wired into `getStaticPaths` so it all happens at build time and never at runtime. The interesting parts weren't the generation itself but the constraints around it. One static template keeps every preview consistent. Generating at build time means the edge just serves files. And the render stays quick mostly because it skips the system-font scan resvg-js doesn't actually need here. If you want the wider context on how the rest of the site is put together, I wrote about [rebuilding it with Astro and Tailwind](/blog/the-new-website), the [discoverability plumbing](/blog/configuring-my-site-for-ai-discoverability) that OG images are one small part of, and the [Astro 7 upgrade](/blog/astro-7) whose Rust compiler keeps the build fast. --- # shadcn/typeset vs Tailwind Typography for Markdown URL: https://morello.dev/blog/shadcn-typeset-vs-tailwind-prose Published: July 10, 2026 Tags: css, tailwindcss, webdev, react shadcn/typeset styles rendered markdown with one CSS file you own. Here's how it works and how it compares to Tailwind Typography and its prose class. You render some markdown, get back a pile of unstyled HTML, and then you style it. Headings, paragraphs, lists, tables, code. You do it for the blog. Then you do it again for the docs. Then a third time for the chat UI that streams model output token by token. Every context, the same elements, slightly different rules. [shadcn](https://x.com/shadcn) shipped a take on this on July 10, 2026, called [shadcn/typeset](https://ui.shadcn.com/docs/typeset). It's worth a look even if you don't use the rest of shadcn/ui, because it isn't a component and it isn't a CLI install. It's one CSS file you copy into your project and own outright. ## What is shadcn/typeset? Typeset is a styling system for rendered HTML and markdown, delivered as a single CSS file. You wrap your content in a `.typeset` container and everything inside it gets styled: headings, paragraphs, lists, tables, code, blockquotes, links, footnotes, even MathML and `
`. ```jsx
{content}
``` `.typeset` turns the styles on. `.typeset-docs` is a preset, a small class you layer on top to tune the look for a specific context. You can define as many presets as you have contexts: one for the blog, one for the docs, one for chat. The thing to internalize up front: there's no npm package and no config layer. As the [changelog](https://ui.shadcn.com/docs/changelog/2026-07-typeset) puts it, "the file lives in your project," nothing to update, nothing to work around. If a heading annoys you, you open the file and change the rule. That's the whole distribution model. This is a different thing from shadcn/ui's [Typography component](https://ui.shadcn.com/docs/components/base/typography), which gives you styled React heading and paragraph components to author by hand. Typeset styles HTML you didn't write, the output of a markdown renderer. ## How do you install shadcn/typeset? You don't run a CLI. As of this release there's no `npx shadcn@latest add typeset`, and expecting one will send you looking for a command that doesn't exist. Instead you build the file in the [interactive builder](https://ui.shadcn.com/typeset), pick your sizes and fonts, and copy the generated `typeset.css` into your project. Then you import it after Tailwind: ```css @import "tailwindcss"; @import "./typeset.css"; ``` Import order matters, and I'll come back to why in a moment. Once it's in, the wrapper from the first snippet is all you need. ## Size, leading, and flow Typeset condenses typographic tuning into three CSS variables it calls "rhythm," and everything else derives from them: ```css .typeset { --typeset-size: 1em; /* base font size */ --typeset-leading: 1.75; /* line-height */ --typeset-flow: 1.25em; /* vertical space between blocks */ } ``` A preset is just those three variables set to different values under a class name: ```css .typeset-chat { --typeset-flow: 1em; --typeset-leading: 1.6; } .typeset-docs { --typeset-size: 15px; --typeset-flow: 1.5em; } ``` Because it's all CSS custom properties, you can override a single value inline without touching the file: ```jsx
{content}
``` The sizing is container-relative rather than a fixed `rem` scale. Below `48rem` the base size gets a `1.125x` bump and then settles back to the raw value on larger screens, so text reads comfortably on a phone without a separate small-screen variant. Colors and borders pull from your app's theme tokens (`--color-foreground`, `--color-muted-foreground`, `--color-border`, `--radius`) with sensible fallbacks, so dark mode just works when your tokens flip. There's no second inverted palette to maintain. One deliberate omission: Typeset doesn't set a `max-width`. Your layout owns the measure. The builder's Measure control adds a `max-width` to the wrapper if you want it, but the stylesheet itself stays out of your layout's business. ## Why your Tailwind utilities win without !important Here's the detail I liked most. Typeset lives in the CSS `@components` layer and uses zero-specificity `:where()` selectors for every element rule: ```css @layer components { .typeset { & :where(h1) { font-size: 1.75em; /* ... */ } & :where(p) { margin-block-start: var(--typeset-flow); } } } ``` The override comes from cascade layers, not specificity. Typeset's rules live in the `@components` layer, and Tailwind's utilities live in the `@utilities` layer, which is declared after it. When rules in two different layers conflict, the later layer wins outright, no matter how specific either selector is. So a utility beats a Typeset rule with no `!important` and no special modifier syntax. Slap `.text-3xl` on a heading and it just wins. `:where()` does a related but separate job: it holds every element rule at zero specificity, so an override that isn't already shielded by a later layer, like plain CSS you write yourself or a rule in the same layer, can still win without a specificity fight. That's also why import order matters. Importing Tailwind first establishes its layer order, which puts `@utilities` after Typeset's `@components`. If you want to pull a whole subtree out, there's an escape hatch: `.not-typeset` (or the `data-not-typeset` attribute), which cascades to descendants including any nested `.typeset` container. ```jsx Untouched component. ``` shadcn is candid that both of these ideas, the `:where()` guard and the opt-out class, are borrowed from [`@tailwindcss/typography`](https://github.com/tailwindlabs/tailwindcss-typography), which has a `.not-prose` of its own. Which brings us to the comparison you actually clicked for. ## shadcn/typeset vs Tailwind Typography The [Tailwind CSS Typography](https://github.com/tailwindlabs/tailwindcss-typography) plugin, and the `.prose` class it hands you, has been the default answer for styling rendered markdown for years, and it's still excellent at what it was built for. Typeset isn't a drop-in replacement or a wrapper around it. It's a different set of trade-offs. The docs lay them out directly: | | Tailwind Typography (`.prose`) | Typeset | | --- | --- | --- | | Sizing | Fixed `rem` scale, `.prose-sm` to `.prose-2xl` | Relative to the container, any size | | Dark mode | `.prose-invert`, a second palette | Your tokens flip, nothing to add | | Overrides | `prose-a:`, `prose-headings:` modifier API | Plain utilities and CSS win | | Streaming | No append-stability contract | Designed for stable appends | | Distribution | npm plugin, generated CSS | One CSS file you own | The way I'd frame it: `.prose` is a well-designed dependency you configure, and Typeset is a starting point you edit. If you like the modifier API and want a maintained plugin tracking Tailwind releases, `.prose` is still a great pick. If you'd rather own the file and delete the rules you don't want, Typeset fits that instinct better. It's the same philosophy as the rest of shadcn/ui: copy the code in, make it yours. ## What makes it streaming-safe? This is the part that made Typeset feel designed for 2026 rather than 2020. If you've built a chat UI that renders an LLM's markdown as it streams, you've watched the layout twitch as each token lands. Typeset treats that as a first-class constraint. The rule is that there are no forward-looking selectors in the layout. `:last-child`, `:has()`, and `:empty` are deliberately kept out of spacing rules, because what they match changes as content appends, which restyles blocks that were already on screen. Spacing flows in one direction only, using `margin-block-start` and never `margin-block-end`: ```css & :where(p) { margin-block-start: var(--typeset-flow); margin-block-end: 0; } ``` The same discipline shows up in tables: the row separators sit on the cells rather than on `tr:last-child`, so appending a row never re-runs a `:last-child` match and shifts the borders above it. When you add a new block to a Typeset container, nothing before it moves. That's a genuinely hard property to get right by hand, and it's baked into the stylesheet. ## What it costs you Two things are worth knowing before you commit. First, the stylesheet leans on modern CSS, though most of it is safe by now: `color-mix()` and `oklch()` colors have been widely supported since 2023, and logical properties like `margin-block-start` work everywhere. The real outlier is [`margin-trim`](https://developer.mozilla.org/en-US/docs/Web/CSS/margin-trim), which as of 2026 still ships only in Safari and other WebKit browsers (16.4 and up). There's no fallback for it, so in Chrome and Firefox that rule quietly does nothing. Worth knowing before you count on the margin trimming it's meant to handle. Second, wide tables wrap to fit by default. Horizontal scroll is opt-in through a `.typeset-scroll` wrapper you have to add in your renderer's table component or a small rehype plugin. It won't happen automatically. ## Should you use it? If you're already in the shadcn/ui world and you're styling rendered markdown, especially anything that streams, Typeset is an easy yes: it's free, it's one file, and the streaming stability alone justifies it. If you're outside that ecosystem and happy with `.prose`, there's no migration to rush. It's an alternative with a clearer answer to a couple of specific problems, not an upgrade you're missing out on. What I appreciate is the framing. shadcn keeps betting that the right unit of distribution for UI is source you own rather than a dependency you configure, and Typeset applies that bet to typography. I made a similar call [rebuilding this site](/blog/the-new-website): the long-form styling here is hand-written CSS wired to theme variables rather than a plugin, for exactly the reason Typeset exists, and I've since [replaced Tailwind with vanilla CSS across the whole site](/blog/replacing-tailwind-with-vanilla-css). The same instinct runs through [generating OG images with Satori](/blog/generating-og-images-with-satori-and-astro): own the design, skip the abstraction. Owning the CSS means when something looks off, you fix the rule instead of fighting the abstraction. Go [build one in the builder](https://ui.shadcn.com/typeset) and read the file it hands you. It's short, and reading it teaches you more about typographic CSS than the docs do. --- # TypeScript 7 Is Here: The Go Compiler Rewrite URL: https://morello.dev/blog/typescript-7-is-here Published: July 9, 2026 Tags: typescript, javascript, webdev, programming TypeScript 7.0 ships the compiler rewritten in Go: 8 to 12x faster builds, the same type system, and a new LSP tooling story. Here's what changed. If you've worked in a large TypeScript codebase, you know the feeling. You save a file, tab away, and wait for the red squiggles to catch up. On a big monorepo, `tsc --noEmit` in CI is the slow step everyone learned to stop watching. The type checker was written in TypeScript, running on a single JavaScript thread, and even with the [JIT compilation that makes modern engines fast](/blog/five-things-you-might-not-know-about-javascript), there's only so fast that can go. That's the thing that just changed. On [July 8, 2026](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/), Microsoft shipped **TypeScript 7.0**: the same compiler you already use, rewritten from the ground up in [the Go language](https://go.dev). The headline number is builds that run 8 to 12 times faster, and after living on the preview for a while, that number holds up. ## What TypeScript 7 actually is TypeScript 7 is a [native port](https://devblogs.microsoft.com/typescript/typescript-native-port/) of the compiler and tooling. There's no new type system here, no new syntax. The team took the existing checker and methodically ported it to Go, keeping the logic structurally identical to the JavaScript version. The [RC announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0-rc/) puts it plainly: the Go codebase "was methodically ported from our existing implementation rather than rewritten from scratch, and its type-checking logic is structurally identical to TypeScript 6.0." That's the part worth internalizing before anything else. Your types don't change. The inference you rely on, the errors you see, the edge cases you've memorized: all the same. Microsoft ran the two implementations against roughly 20,000 test cases. About 6,000 of those flag at least one error under TypeScript 6, and in [all but 74](https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/) of them, TypeScript 7 flags an error too. This is a performance release wearing a major version number, not a language release. ## Why Go, of all things This was the question everyone asked when [the port was announced](https://devblogs.microsoft.com/typescript/typescript-native-port/) back in March 2025. Why not Rust? Why not C#, Microsoft's own language? The [design notes](https://github.com/microsoft/typescript-go/discussions/411) give a practical answer. The compiler does an unusually large amount of graph work, walking trees up and down through polymorphic nodes, and Go makes that ergonomic. It gives you control over memory layout and allocation without forcing every line of the codebase to think about ownership. And because a batch `tsc` run terminates when it's done, the compiler can lean on Go's garbage collector cheaply, or skip collection almost entirely, since the process exits and the OS reclaims everything anyway. There's also the boring reason, which is usually the real one: idiomatic Go looks a lot like the existing TypeScript codebase. Functions and data structures, not deep object hierarchies. That resemblance is what made porting hundreds of thousands of lines tractable instead of a multi-year rewrite. [Anders Hejlsberg](https://en.wikipedia.org/wiki/Anders_Hejlsberg), who has been on this since the start, has described Go as the lowest-level language that still offered native code, a garbage collector, and good concurrency without fighting the transition from TypeScript's coding style. None of that made Rust the wrong tool in general; it's having its own moment across JavaScript tooling, from bundlers to the [Rust rewrite of pnpm](/blog/pnpm-v12-rust-rewrite) to Bun's own [Zig-to-Rust rewrite](/blog/bun-14-rust-rewrite). ## How much faster is TypeScript 7? Here's what the speedup looks like on real projects, measured for the [7.0 release](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/) against TypeScript 6: | Project | TS 6 | TS 7 | Speedup | | --------- | ------: | -----: | ------: | | VS Code | 125.7s | 10.6s | 11.9x | | Sentry | 139.8s | 15.7s | 8.9x | | Bluesky | 24.3s | 2.8s | 8.7x | | Playwright| 12.8s | 1.47s | 8.7x | | tldraw | 11.2s | 1.46s | 7.7x | A full check of the VS Code codebase went from over two minutes to about ten seconds. Memory use dropped too, somewhere between 6% and 26% depending on the project. The build-time number is the one that gets quoted, but the one you'll feel every day is editor responsiveness. Loading the VS Code project in the editor used to take [around 9.6 seconds](https://devblogs.microsoft.com/typescript/typescript-native-port/) before the language service was ready; on the native port it's about 1.2. That's the gap between "the squiggles show up when I need them" and "I've already tabbed away." Where does the speed come from? Two places. Native code is faster than [JIT-compiled JavaScript](/blog/five-things-you-might-not-know-about-javascript) for this kind of work, and Go lets the compiler use [shared-memory multithreading](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/) that the single-threaded JavaScript version simply couldn't. Build mode now runs multi-threaded on a single project and compiles multiple projects in parallel, so the whole graph gets to use your cores instead of one of them. ## How to install TypeScript 7 At GA, the compiler ships from the package you already have. Install the latest `typescript` and the binary is still `tsc`: ```sh npm install -D typescript ``` If you were on the preview before GA, note that the names have settled. During the preview the package was `@typescript/native-preview` and the binary was `tsgo`, run like this: ```sh npx tsgo --project ./src/tsconfig.json ``` From the RC onward that folds back into the normal `typescript` package and the `tsc` command you've always used, so most projects change a version number and nothing else. The [typescript-go repo](https://github.com/microsoft/typescript-go) that housed the port is expected to merge back into the main `microsoft/TypeScript` repository over time. ## The 6.0 / 7.0 split The version jump from 5.x is deliberate, and it comes with a plan. TypeScript [6.0](https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/), released in March 2026, is the [final JavaScript-based version](https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/). There's no 6.1 on the roadmap, only patch releases for security fixes or serious regressions. It exists as the bridge: 6.0 shipped the new defaults and marked a set of things deprecated, giving you a release to clean up against while still running the old, familiar implementation. TypeScript 7.0 then adopts those 6.0 defaults and turns the deprecations into [hard errors](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/). A few compiler options are gone or changed: - `target: es5` is removed. - `baseUrl` is no longer supported. - `types` now defaults to `[]` instead of pulling in everything under `node_modules/@types`. - `rootDir` defaults to `./`. If a jump straight to 7.0 surfaces too much at once, there's a side-by-side `@typescript/typescript6` package so you can keep the old compiler available while you migrate. ## What doesn't work in TypeScript 7 yet This is the part to be honest about, because it's where the release will actually bite. The editor story moved to a [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) architecture, which is a good long-term direction. But TypeScript 7 does not yet expose a stable programmatic API, and a lot of the ecosystem is built on that API. The concrete fallout: template type-checking for [Vue](https://vuejs.org), [Svelte](https://svelte.dev), [Astro](https://astro.build), MDX, and [Angular](https://angular.dev) isn't supported on TypeScript 7 yet. Those tools reach into the compiler's internals to type-check the parts of your app that live outside `.ts` files, and until the public API stabilizes, they can't do that against the Go implementation. So if you're building a Vue or Svelte app, or anything that leans on a framework's language tooling, the 10x number is real but you can't fully collect it yet. The compiler is ready; the layer your framework plugs into isn't. This is worth checking against your own stack before you upgrade, since it's the difference between a version bump and a broken editor. ## Should you upgrade to TypeScript 7? If you've got a plain TypeScript project, a library, a Node service, a build script, the answer is easy: yes, and soon. The type checking is the same, the CI step gets dramatically faster, and the migration is mostly a version bump plus whatever 6.0 already warned you about. Test the bump in an isolated [git worktree](/blog/git-worktrees-are-underrated) so your main checkout keeps building while you shake out any surprises. If you're in a framework app that depends on template type-checking, wait and watch. Track the [programmatic API work](https://github.com/microsoft/typescript-go), see when your framework's tooling lands support, and upgrade then. There's no rush and no downside to letting it settle. Either way, this is the most significant thing to happen to TypeScript's infrastructure in years. The type system stayed still on purpose so that the thing underneath it could get an order of magnitude faster. It's the same kind of deliberate bet [SolidJS 2.0 is making with async](/blog/solidjs-2-react-developers-first-look): keep the surface still, rebuild the engine. --- # The HTTP QUERY Method: Safe Reads with a Body URL: https://morello.dev/blog/the-new-http-query-method Published: July 7, 2026 Tags: http, webdev, api, backend HTTP finally has a safe, idempotent, cacheable method that carries a body. Here's what QUERY (RFC 10008) is, why it exists, and how to use it. If you've ever built a search endpoint, you've hit this wall. Your query has filters, sort orders, a nested set of facets, maybe a geo bounding box. It doesn't fit in a URL, and cramming it into query string params is ugly and fragile. So you reach for `POST /search`, send the whole thing as a JSON body, and quietly accept that you've just lied about what the request does. It's not creating anything. It's a read. But POST is the only tool that lets you attach a body without fighting the platform. That gap finally got filled. In June 2026 the IETF published [RFC 10008](https://www.rfc-editor.org/info/rfc10008/), which defines the HTTP QUERY method: a new verb built for exactly this case. ## The two bad options Every read that needs structured input has been stuck choosing between GET and POST, and both are wrong in their own way. GET is the semantically correct choice. It's [safe](https://www.rfc-editor.org/rfc/rfc9110#name-safe-methods) (the client isn't asking to change anything), it's [idempotent](https://www.rfc-editor.org/rfc/rfc9110#name-idempotent-methods) (retrying it is fine), and it's cacheable. The problem is the body. RFC 9110 is explicit that [content in a GET request has no defined semantics](https://www.rfc-editor.org/rfc/rfc9110#name-get), and sending one may cause some implementations to reject the request. So your query has to live in the URI, where you run into unknown length limits across proxies and servers, encoding overhead, and the query landing in access logs and browser history. POST solves the body problem and creates a new one. It carries any payload you want, but it's neither safe nor idempotent by definition. Intermediaries won't cache it, clients won't retry it automatically after a dropped connection, and anything inspecting traffic has to assume the request might have side effects. You get the body, you lose everything that made the request honest. QUERY is the missing third option: a method that carries a body _and_ keeps the semantics of a read. ## What QUERY actually is The spec, authored by Julian Reschke, [James Snell](https://github.com/jasnell), and Mike Bishop, describes it in one sentence: > A QUERY requests that the request target process the enclosed content in a safe and idempotent manner and then respond with the result of that processing. So a QUERY request looks like this: ```http QUERY /products HTTP/1.1 Host: example.com Content-Type: application/json Accept: application/json { "filters": { "category": "keyboards", "inStock": true }, "sort": [{ "field": "price", "order": "asc" }], "page": { "size": 20 } } ``` The three properties that matter: **It's safe and idempotent.** The client isn't requesting a state change, and the request can be retried or repeated without worrying about partial effects. This is what POST could never promise. **It's cacheable.** A cache is allowed to store the response and use it to satisfy later QUERY requests. The catch is that the cache key has to include the request content, not just the URI, since the body is what distinguishes one query from another. Two QUERYs to the same URL with different bodies are different requests. In practice, getting a CDN to key on anything beyond the URI is its own fight, as I found out trying [Accept-based content negotiation on Cloudflare](/blog/configuring-my-site-for-ai-discoverability). **The response isn't a representation of the URI.** Unlike GET, where the response _is_ the resource at that URL, a QUERY response is the result of running your query over some data scoped to the target. `GET /products` returns the products resource; `QUERY /products` returns whatever your query selected from it. ## The details that bite A few rules are worth knowing before you wire this up. `Content-Type` is mandatory. The server MUST reject the request if the `Content-Type` field is missing or inconsistent with the body. In practice you'll see a `400` for a missing media type, a `415 Unsupported Media Type` for one the server doesn't handle, and a `422 Unprocessable Content` when the body parses fine but doesn't make sense as a query. There are two response headers that trip people up because they sound alike: - **`Content-Location`** points at a resource representing the _results_ of this query. A client can GET that URL later to fetch the same results again. - **`Location`** points at a resource representing the _query itself_. A client can GET that URL to re-run the query without resending the body, which is handy for turning a heavy query into a shareable link. Results points to the answer; Location points to the question. The spec also nudges you away from HTTP range requests for pagination. Range semantics technically apply, but most query formats already have their own paging built in (think SQL's `FETCH FIRST`), and that's the mechanism you should reach for. ## Can you use HTTP QUERY today? On the client, yes, already. The Fetch API and libraries like axios accept arbitrary method strings, so nothing is stopping you from sending one right now: ```js const res = await fetch("/products", { method: "QUERY", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filters: { category: "keyboards" } }), }); ``` One gotcha before you do: pass the method in uppercase. [`fetch` only normalizes the case](https://fetch.spec.whatwg.org/#concept-method-normalize) of a fixed set of methods (`DELETE`, `GET`, `HEAD`, `OPTIONS`, `POST`, `PUT`), and QUERY isn't on it. Write `method: "query"` and it goes out verbatim, which a case-sensitive server will reject. The friction is everywhere else in the stack. QUERY isn't a [CORS-safelisted method](https://fetch.spec.whatwg.org/#cors-safelisted-method), so cross-origin QUERY requests trigger a preflight `OPTIONS`. Your server has to answer it with `Access-Control-Allow-Methods: QUERY` or the browser blocks the real request. And anything that whitelists methods will reject QUERY until you tell it not to: reverse proxies, WAFs, API gateways, `limit_except` blocks in nginx. The method passing through the wire is the easy part; the config that guards the wire is where you'll spend your time. Framework and server support is still landing. New HTTP methods don't come around often. The last one most people reached for was PATCH back in 2010, so the ecosystem moves slowly. Expect native routing helpers and middleware to fill in over the next couple of years rather than overnight. ## Should you rush to switch? Probably not, and there's no need to. Your `POST /search` endpoints work and aren't going anywhere. QUERY is the more correct tool, not an urgent migration. What it does give you is a real answer to a question we've been hacking around for years. It's the same pattern the [Cross-Origin Storage API](/blog/cross-origin-storage-api) and [WebMCP](/blog/webmcp-making-your-site-usable-by-ai-agents) got right: the platform finally naming something we'd been faking. QUERY isn't the only 2026 addition that replaced a workaround; the Navigation API, container style queries, and the `:open` pseudo-class all [became Baseline this year too](/blog/baseline-2026-web-platform-apis). When you're designing a new read endpoint that needs a structured body, you now have a method that says exactly what it means: this is a safe, repeatable, cacheable read, and here's the query in the body where it belongs. That's worth reaching for on the next thing you build, even if the old endpoints stay put. --- # SolidJS 2.0 for React Developers: A First Look URL: https://morello.dev/blog/solidjs-2-react-developers-first-look Published: July 2, 2026 Tags: solidjs, react, javascript, webdev SolidJS 2.0 lands first-class async, a new Loading boundary, and signals. Here's what caught my eye after years of writing React. I've shipped React for the better part of a decade. Hooks, Suspense, Server Components, the whole tour. So when the **SolidJS 2.0** beta showed up in my feed with the tagline ["The `` is Over,"](https://github.com/solidjs/solid/releases/tag/v2.0.0-beta.0) I rolled my eyes a little and then clicked anyway. I'm glad I did. Solid has been the framework React people admire from a distance for years, and 2.0 is the release that finally made me install it and poke at the reactivity model instead of just reading about it. This post is my honest first look: what's new, what a React developer will recognize, and where Solid quietly does something React can't. ## What is SolidJS 2.0? [SolidJS](https://www.solidjs.com) is a UI library with React-like JSX and a completely different engine underneath. Instead of re-rendering components and diffing a virtual DOM, it uses [**fine-grained reactivity**](https://docs.solidjs.com/concepts/intro-to-reactivity): your component runs once, and updates flow directly to the exact DOM nodes that depend on a piece of state. The 2.0 line is currently in beta. The first public build, `v2.0.0-beta.0`, landed on March 3, 2026, and the team skipped the alpha phase entirely because the milestones planned for it stopped feeling worth their own release. You can try it today: ```sh pnpm add solid-js@next ``` The headline of the whole release is async. Solid's reactive graph now understands promises natively, and a lot of the API changes fall out of that one decision. ## Fine-grained reactivity, from a React brain Here's the mental model shift, and it's the thing to internalize before anything else makes sense. In React, state changes re-run your component function. React then builds a new virtual DOM tree and diffs it against the old one to figure out what actually changed. `useMemo`, `useCallback`, and dependency arrays exist to keep that re-render machine from doing too much work. The [React Compiler](https://react.dev/learn/react-compiler) automates a lot of that memoization now, but the underlying model is the same: the component re-runs and React diffs the result. In Solid, the component body is setup code. It runs a single time. When you read a signal in your JSX, Solid records that specific dependency and wires it straight to the DOM. Change the signal later and only that text node or attribute updates. No component re-render, and no dependency array to keep honest. ```jsx function Counter() { const [count, setCount] = createSignal(0); // This whole function runs ONCE. Only the text node updates on click. return ; } ``` The tell for a React dev: `count` is a function, not a value. You call `count()` to read it. That call is what subscribes the surrounding computation to changes. Once it clicks, the absence of a dependency array stops feeling like something is missing and starts feeling like a bug class that no longer exists. ## Why async is the real story of SolidJS 2.0 This is where 2.0 earns its version bump. In Solid 1.x, async data meant `createResource` and wrapping things in ``. In 2.0, a derived computation can just return a promise, and the graph handles suspending and resuming for you. ```jsx // Solid 1.x const [data] = createResource(userId, fetchUser); // Solid 2.0: the promise flows through the reactive graph const data = createMemo(() => fetchUser(userId())); ``` Suspense gets replaced by ``, and the semantics are the part I actually care about. `` is scoped to *initial* readiness. It shows a fallback while the subtree can't render anything yet, and then it stays out of your way. When `userId()` changes and the data refetches, the UI doesn't tear itself down and flash a spinner. It holds the old content until the new content is ready. To show that a refresh is in flight, you reach for `isPending`, which reports on pending reactive work without unmounting anything: ```jsx const users = createMemo(() => api.listUsers()); const refreshing = () => isPending(() => users()); <> }> ; ``` React 19 gets you most of the way here: put the update in a transition with [`useTransition`](https://react.dev/reference/react/useTransition), and Suspense holds the old content on screen instead of flashing its fallback, with `isPending` reporting the refresh. The difference is that React makes you opt into that at each call site, while Solid bakes the distinction between "we have nothing to show yet" and "we're refreshing what's already on screen" into the primitives themselves. I take this apart properly in a [follow-up deep dive on Solid 2.0's async data](/blog/solidjs-2-async-data). ## How do mutations work in SolidJS 2.0? For a long time React had no blessed way to do writes, so you rolled your own or reached for a data library. React 19 changed that with [Actions](https://react.dev/blog/2024/12/05/react-19) and the [`useOptimistic`](https://react.dev/reference/react/useOptimistic) hook, so this is a spot where the two frameworks have converged. Solid 2.0's version is `action` for mutations, plus `createOptimistic` and `createOptimisticStore` for optimistic updates, with `refresh` to revalidate afterward. ```js const [messages, setMessages] = createOptimisticStore(() => chatServer.loadMessages(), []); const sendMessage = action(function* (next) { setMessages((m) => { m.push(next); // optimistic: show it instantly }); yield chatServer.sendMessage(next); // await the server refresh(messages); // reconcile with the source of truth }); ``` The generator function is doing real work here. Each `yield` is a point where the action awaits, and the reactive system tracks the optimistic state until the real value lands and reconciles. Solid has been circling this for a while: `@solidjs/router` already shipped [`createAsync`](https://docs.solidjs.com/solid-router/reference/data-apis/create-async) and `action` for data loading and mutations. 2.0 takes that thinking and builds async and actions into the core reactive graph, so the patterns aren't router-specific anymore. ## Deterministic batching, and a gotcha One behavioral change will trip you up if you're not ready for it. Writes are batched on a microtask, and reads don't reflect a write until the batch flushes. ```js const [count, setCount] = createSignal(0); setCount(1); count(); // still 0: the update is queued on the microtask flush(); // apply queued updates synchronously count(); // now 1 ``` `flush()` forces the queue to apply right away when you need to read the result immediately, like focusing an input after a state change. Coming from React's synchronous batching it takes a moment to adjust, but the model is predictable, and that matters once async is woven through everything. ## The renames a React developer should know Solid 2.0 is a major version, so it cleans house. Since this is beta, some of these identifiers could still shift before stable, but the direction is set, and the [migration guide](https://github.com/solidjs/solid/blob/next/documentation/solid-2.0/MIGRATION.md) tracks where things stand today. The changes that matter most coming from React: - **`` → ``**, with the initial-readiness semantics above. `` becomes ``. - **`createEffect` is split** into a compute phase (what to track) and an apply phase (what to run with the result). Instead of reading signals inside one callback, you pass the two separately: ```js // Solid 1.x createEffect(() => apiCall(signalB())); // Solid 2.0 createEffect(signalB, apiCall); createEffect( () => [signalA(), signalB()], ([a, b]) => a && apiCall(b), ); ``` If you've ever shipped a bug because a value was in a `useEffect` body but missing from the dependency array, separating the two is a direct answer to that. And in practice you reach for `createEffect` far less than you did in 1.x, since async and derived state now cover a lot of what used to send you to an effect. - **`onMount` → `onSettled`** (the closest replacement rather than a straight rename), reflecting the async-aware lifecycle. - **`` → ``**; in that non-keyed mode the row is passed as an accessor, matching the old `` stability model. - **Store setters are draft-first by default.** You mutate a draft instead of threading a path. This is 1.x's `produce` behavior promoted to the default, so you no longer wrap the callback, with a `storePath` helper as the opt-in escape hatch for the old path style: ```js // 2.0 default: mutate the draft setStore((s) => { s.todos[id].done = true; }); // Legacy path style, opt-in setStore(storePath("todos", id, "done", true)); ``` - **`classList` is folded into `class`**, which now takes strings, arrays, and objects. - **`use:` directives are removed** in favor of `ref` directive factories. - **Context is simpler.** The context is directly usable as the provider, so `Context.Provider` is gone. A context created without a default is typed as its value instead of `T | undefined`, and `useContext` throws `ContextNotFoundError` when there's no provider above it; a context created with a default still returns that default: ```jsx const Theme = createContext(); // no default // 2.0: the context is its own provider {/* ... */}; ``` ## Signals are heading toward the standard The word "signals" is everywhere now, and it's worth knowing why. There's a [TC39 Signals proposal](https://github.com/tc39/proposal-signals) at Stage 1, with design input from the people behind Angular, Vue, Svelte, Preact, Qwik, MobX, and Solid, among others. Solid's creator Ryan Carniato has written about fine-grained reactivity for years (his [evolution of signals in JavaScript](https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob) piece is a good entry point), and Solid is one of the frameworks feeding into the proposal. Worth being precise here, because it's easy to overstate: Solid 2.0 does not ship the TC39 API as its core. The proposal is synchronous, and Solid 2.0's whole thing is *async* reactivity, which goes further than what's currently on the table. Solid influences and tracks the standard rather than implementing it. But the fact that the primitive Solid has bet on for years is now a serious effort to fold it into [the JavaScript language itself](/blog/five-things-you-might-not-know-about-javascript) tells you the industry is drifting toward the model React chose not to adopt. It's the same trajectory [TypeScript's Go rewrite](/blog/typescript-7-is-here) just traced: a bet on a different model that looked niche until it wasn't. Svelte 5 already rebuilt its reactivity around signals with its [runes](https://svelte.dev/blog/runes) API, and it's unlikely to be the last framework to make the jump. ## Should you drop React? No. But watch this closely I'm not migrating my day job to Solid this quarter, and I don't think you should either. It's a beta, the ecosystem is smaller than React's, and API names are still moving. That's the honest read. But 2.0 is the most interesting frontend release I've looked at in a while, precisely because it isn't chasing React. It takes fine-grained reactivity seriously and follows the idea all the way through to async, and the result is a set of primitives that make the hard parts of React (stale closures, dependency arrays, Suspense re-triggering, figuring out where mutations should live) feel like problems you can just stop having. If you write React and you've never actually run Solid, this is the release to spend a weekend on. Spin up a project with `solid-js@next`, build something small with `createSignal` and ``, and pay attention to how much of your usual React vigilance you can put down. The beta is live at [github.com/solidjs/solid](https://github.com/solidjs/solid/releases/tag/v2.0.0-beta.0), and the [announcement discussion](https://github.com/solidjs/solid/discussions/2596) is worth reading in full if any of this piqued your interest. --- # Rebuilding Defrag98: Getting the Details Right URL: https://morello.dev/blog/defrag98-rebuild Published: June 30, 2026 Tags: react, vite, webdev, retro How I rebuilt Defrag98 to feel like the real thing: a truer simulation, canvas rendering, a lighter Vite stack, and full offline support. There's a specific kind of satisfaction that comes from watching the Windows 98 defrag screen do its thing. Clusters shuffling around. That progress bar crawling. The sense that your computer was doing something _important_, even if you had no idea what. That's the feeling I wanted to bottle when I built [the first version of defrag98.com](/blog/windows-98-defrag-simulator) back in 2024. The project took off beyond anything I'd expected. It got picked up by [Hacker News](https://news.ycombinator.com/item?id=40962195) and [The Verge](https://www.theverge.com/2024/7/14/24198206/take-a-moment-to-reflect), and people from all over the world spent time staring at a browser tab the way their younger selves once stared at a CRT monitor. Mission accomplished. But the more I looked at it, the more I noticed the cracks. ## It wasn't quite right The most significant problem was the simulation logic. The original Windows 98 defragmenter didn't march through clusters sequentially. It read blocks, wrote them, and shuffled them around in a messier, more organic process that made watching it genuinely hypnotic. My first implementation was too orderly. It _looked_ like a simulation. The real one looked like a machine thinking. The legend was missing too. The real Windows 98 defragmenter had a dedicated button that opened a dialog explaining what each block color meant: optimized, reading, writing, bad cluster. Without it, the simulation was just a pretty light show. Now it has one. On the visual side, the cluster grid also had a black background, when the real defragmenter used white. A small detail, but exactly the kind of thing that breaks the spell for anyone who actually remembers the original. Then there was the progress bar. The real utility didn't draw a smooth, continuous fill. It revealed a row of chunky segmented blocks one at a time, with a "% Complete" caption underneath. Mine was too modern, too clean. Now it fills block by block the way it should, and the window is sized to give it room to breathe. Even the controls were missing some manners. The original asked you to confirm before stopping a defrag, and it told you plainly when the job was _Paused_ or _Stopped_. Small things, but they're the difference between a screensaver and a tool you can actually operate. ## The rebuild: Next.js to Vite, and a faithful simulation I moved the project from Next.js to Vite + React. This might sound like a lateral move, but it was the right call for what [defrag98.com](https://defrag98.com) actually is: a single-page, entirely client-side app with no routing, no server-side data, and no need for SSR. Next.js is excellent at what it does, but fighting its SSR model to build something that runs entirely in the browser felt like wearing a suit to go swimming. Vite gave back the dev experience that SSR had been quietly taxing. Faster builds, instant HMR, and a mental model that actually matches the app. While I was in there, the rest of the stack got a refresh too: Tailwind CSS v4 through its native Vite plugin (no more PostCSS pipeline), Base UI for the accessible dialogs, selects and checkboxes, Zustand for state, TypeScript 6, and the Rust-based oxc toolchain (oxlint and oxfmt) in place of ESLint and Prettier for near-instant linting and formatting. The bigger change was moving cluster rendering to a `` element. Instead of creating and updating hundreds of DOM nodes, I'm now drawing directly to a 2D canvas context. The number of clusters rendered adapts to whichever disk size you select, so a larger virtual drive produces a denser, more satisfying grid. The difference is easy to feel: the animation is smoother, the browser is happier, and it scales without complaint. I also size the canvas before the first paint, which killed the layout shift that used to jump around on mobile. On the simulation side, I rewrote the defrag algorithm to properly model read and write operations on individual clusters, with non-sequential processing that mirrors how the original utility actually behaved. If you remember the way blocks would jump around seemingly at random before gradually settling into order, that's what it does now. The sound got some attention too. The hard disk loop and the completion chime were re-encoded to a fraction of their old size (the HDD loop went from over a megabyte to around 350KB), shipped as Opus/WebM with an AAC fallback, and the Win98 fonts are preloaded now so nothing flashes on first paint. There are also toggles in Settings to mute the hard disk sound or the mouse clicks, and your choice is remembered the next time you visit. When a defrag finishes, you now get the authentic Windows 98 "Disk Defragmenter" message box telling you it's done, chime and all, instead of the run just quietly ending. A couple of things changed off-screen that I care about as much as the visible ones. I dropped Google Analytics and Tag Manager in favor of Cloudflare's cookieless Web Analytics, so there's no tracking cookie and no consent banner getting between you and the nostalgia. The site is hosted on Cloudflare Workers as static assets, and it ships an `llms.txt`, the [same AI-discoverability setup I detailed for this blog](/blog/configuring-my-site-for-ai-discoverability), so the AI crawlers that come poking around get a clean description of what this thing is. [defrag98.com](https://defrag98.com) also ships a web app manifest, so on supported browsers you can install it to your device and launch it like a native app. And it now runs fully offline: a service worker built with vite-plugin-pwa and Workbox precaches the app shell and every runtime asset, the fonts, cursors, audio, and icons, so once you've opened it once you can defragment a virtual drive on a plane with no signal. New builds activate quietly on the next launch, with no update prompt to nag you. Feels right for something built to feel like software from another era. There's also a hidden Easter egg somewhere in the app. I'll leave it at that. If you grew up on Windows 98, you'll know it when you see it. ## A quieter ask The original version had a donation banner styled as an authentic Windows ME system notification: yellow background, full viewport width, sliding down from the top of the screen five seconds into your session. It was accurate to the era. It was also an interruption, landing right in the middle of the nostalgic moment it was supposed to be protecting. The data confirmed it. Despite solid traffic, very few visitors converted to donors. The new approach takes a cue from pre-release Windows software, the small line of unobtrusive text in the corner of the screen that says something like _"This is a pre-release version"_. I added a quiet line in the bottom-right of the desktop area, linking to my [Buy Me a Coffee page](https://www.buymeacoffee.com/morellodev). It's there if you want it. It doesn't demand your attention. The only other time donating comes up is in that completion message box, once the defrag has actually finished and you've had your moment: a gentle Yes or No, never an interruption. The minimum donation has also dropped from $5 to $3, because the goal was never to extract money. It's to help cover the domain and hosting costs that keep [defrag98.com](https://defrag98.com) running for anyone who gets something out of it. ## Why this still matters to me I've received $148 in total donations since launching. That's not a business. But it's proof that people feel something when they use it, enough to voluntarily send a few dollars to a stranger on the internet for a free toy. That's the part that keeps me iterating on this instead of moving on. It's not a portfolio piece. It's a tiny time machine, and apparently enough people want to visit 1998 for a few minutes that keeping the lights on feels worth caring about. If you haven't tried it yet, [go defragment something](https://defrag98.com). If you used to stare at this screen as a kid, I hope it hits the way I intended. And if you find the Easter egg, well, you'll know what to do. --- # Configuring My Site for AI Discoverability URL: https://morello.dev/blog/configuring-my-site-for-ai-discoverability Published: April 20, 2026 Tags: ai, seo, cloudflare, webdev How I set up this site for GEO. Raw Markdown, llms.txt, Content-Signal, and the Cloudflare bits that tie it all together. A growing share of web traffic doesn't come from people anymore. It comes from models reading on their behalf. ChatGPT, Claude, Perplexity, Copilot. They fetch a handful of pages, summarize, and ship the answer back. If your site isn't readable by those agents, you don't exist to them. People are calling this [GEO](https://en.wikipedia.org/wiki/Generative_engine_optimization), short for Generative Engine Optimization. It overlaps with SEO but the priorities are different. Agents don't care about your layout. They care about your prose, your metadata, and how many tokens it costs them to read you. This post covers how I configured this site for GEO. The first half is framework-agnostic. The second half is specific to my setup on Cloudflare, and includes a deliberate choice that fails a popular GEO audit. I'll explain why. (The site itself is a terminal-styled static [Astro build](/blog/the-new-website), if you want the design and stack story first.) ## Part 1: general GEO techniques ### Serve raw Markdown alongside HTML The single biggest GEO win is giving agents a version of each page without the navigation, styling, and scripts. HTML is designed for browsers. Markdown is designed for readers, human or otherwise. Agents spend their context window on your prose, not your DOM. Every blog post on this site has a mirror URL with a `.md` suffix: - `/blog/my-post` is the full HTML page for humans - `/blog/my-post.md` is the raw Markdown, served as `text/markdown` In Astro, this is a two-line route at `src/pages/blog/[slug].md.ts`: ```ts {4} export const GET = async ({ params }) => { const post = await getPostById(params.slug); return new Response(formatPostMarkdown(post), { headers: { "Content-Type": "text/markdown; charset=utf-8" }, }); }; ``` Both variants are pre-generated at build time. Same content, **a fraction of the payload** for an agent to consume: on the posts here, the Markdown mirror is under a fifth the size of the rendered HTML once you strip out the navigation, inline styles, scripts, and structured data. ### Advertise the Markdown version in `` Agents landing on the HTML need to know the Markdown exists. A single `` in the head does it: ```html ``` Browsers ignore this tag. Agents that parse the head follow it. ### Publish an `llms.txt` index [`llms.txt`](https://llmstxt.org/) is a convention for a Markdown file at the root of your site listing your content with short descriptions and links. Think of it as a sitemap an LLM can actually read. I ship two variants: - `/llms.txt` is the index. Title, description, one line per post with a link to its `.md` version. - `/llms-full.txt` is the full corpus. Every post body concatenated into a single response. Why both? An agent researching a specific topic can fetch `llms.txt`, pick the relevant links, and pull them. An agent doing deep research on the site as a whole fetches `llms-full.txt` once and has everything it needs in one request. Either way there's no crawling. ### Declare your AI stance in `robots.txt` `robots.txt` now carries a `Content-Signal` directive for AI use. Mine reads: ```txt {2} User-agent: * Content-Signal: search=yes, ai-train=no, ai-input=yes Allow: / Sitemap: https://morello.dev/sitemap-index.xml ``` Three independent knobs: - `search=yes` lets search engines index - `ai-train=no` says my content is not for training data - `ai-input=yes` says my content _can_ be retrieved and used as input for AI answers This is the stance I'm comfortable with. I want to show up when someone asks Claude about something I've written; I just don't want my posts absorbed into the next base model. > Whether any given operator actually honors this is another question. The signal's there regardless, and I'd rather be on record than silent about it. ### Add structured data that actually describes the content Most blogs ship JSON-LD schema by reflex. Few of them include the fields that help a generative engine decide whether your article is worth fetching. On each post I emit a `BlogPosting` graph with: - `wordCount` and `timeRequired` (ISO 8601 duration), so an agent can estimate how much context it'll spend before fetching - `author` linked to a `Person` node with `knowsAbout` so the entity is grounded in real topics - `BreadcrumbList` for site hierarchy All of it goes into a single `@graph` per page rather than scattered `