WRITING / Taste

Why I hand-wrote a JavaScript framework in 2026

Why summit.js puts Alpine-style markup on fine-grained signals and a hand-written expression interpreter that runs under a strict Content Security Policy.

There is a specific kind of waste that stops being funny once you have shipped it a few times: a page whose only interactive behaviour is a button that opens a panel, served with two hundred kilobytes of framework, a build pipeline, a node_modules directory, and a deploy step that can fail.

The panel is four lines of logic. Everything else is scaffolding for the four lines.

Summit.js is my answer to that. The short version of the pitch: Alpine got the ergonomics right and stopped short on the engine. Behaviour belongs on the element it affects. That instinct is correct, and it is why runtimes that sprinkle behaviour into HyperText Markup Language (HTML) keep getting reinvented. I wanted the same authoring model on a real reactivity engine, with a real answer for Content Security Policy (CSP), and with no build step anywhere in the argument.

Behaviour belongs next to the thing it changes

Here is the whole hello world. There is no bundler and no step between writing it and it working.

<script src="https://velofy.github.io/summitjs/summit.min.js" defer></script>

<div s-data="{ open: false }">
  <button @click="open = !open">Toggle</button>
  <p s-show="open">Hello from Summit</p>
</div>

This authoring model keeps coming back because it collapses the distance between cause and effect. In a component framework, finding out what makes a panel open means opening a component file, tracing a prop, finding the state that owns it, and checking whether a parent re-renders. Here the answer is on the element. You read one line.

That property matters more now than it did five years ago, because a large share of frontend code is written by an artificial intelligence (AI) agent that has to hold the relevant context in one window. Locality is no longer a stylistic preference. It is a budget.

Where the classic engines stop

The HTML-first runtimes generally implement reactivity as effects that re-run. Something in a scope changed, so the effects registered against that scope run again, and each one reasserts its piece of the Document Object Model (DOM). It works. It is also coarser than it needs to be: an effect that reads five values re-runs when any of the five change, even when the change touches something it never displays.

Summit has its own fine-grained signal core instead. Reading a value inside a reactive context subscribes that context to that value. Writing the value notifies exactly the subscribers that read it, and nothing else. A change updates only the DOM that read the value that changed.

The difference shows up in the shape of the work, not in a benchmark you can screenshot. On a dense admin table where one cell changes, the coarse model re-runs the effect that owns the row; the fine-grained model touches one text node. On a page with a header clock and a form, ticking the clock does not walk the form. You stop needing to think about where to draw component boundaries to avoid re-render cascades, because the boundary is the value.

The same core is available on its own, so the reactivity is not welded to the directive layer. That was deliberate: an engine you can only reach through attributes is an engine you cannot test in isolation.

The eval problem

This is the part that decided the architecture, and the part most HTML-first runtimes quietly lose.

An attribute like @click="open = !open" contains a string. Something has to turn that string into behaviour. The path of least resistance is new Function(...), which is a compiler you get for free from the runtime. It is also exactly what a strict CSP exists to forbid. A CSP without unsafe-eval is one of the highest-leverage headers you can set, because it turns a whole class of injection bugs from remote code execution into a console error.

So there are three options: give up the CSP, ship a separate limited build where expressions are pre-compiled and most of the expressive syntax is gone, or write an interpreter.

Summit writes the interpreter. Expressions are parsed and evaluated by hand-written code, never handed to the JavaScript engine as source. That one decision has three consequences:

  • A strict CSP works out of the box, with no unsafe-eval and no special build.
  • The expression language is a closed set, so what an expression can reach is a property of the interpreter rather than of the page’s global scope.
  • Markup generated at runtime, by a template, a content management system (CMS), or a model, runs under the same constraints as markup you wrote by hand.

That last point is the one I care about most. If you are going to let a machine write markup, the runtime that executes it should not also be a general-purpose code loader.

The features that are boring until you need them

A framework earns its size in the places where the simple version runs out. Three of those places came up often enough that they are in the core rather than in a plugin:

Keyed list reconciliation

Rendering a list is easy. Rendering a list that changes without destroying and rebuilding every node, and so losing focus, scroll position and input state, is not. Summit does keyed reconciliation on s-for, so reordering a list moves nodes instead of recreating them. Without it, you find out mid-project that your select boxes reset on every sort.

s-if on any element

The classic runtimes restrict conditional rendering to <template> wrappers. That is an implementation detail leaking into your markup: you add a wrapper element that exists only to satisfy the framework, and it breaks your grid or flex layout because there is now an extra box in the tree. Summit puts s-if on any element.

Cached computed getters

A getter in a scope that runs on every read is a performance trap that scales with how readable you made your template. Summit caches computed getters and invalidates them through the same signal graph as everything else, so you can put a derived value in a getter and read it in six places without paying six times.

What 16KB has to earn

Summit is about 16 kilobytes (KB) gzipped. The comparison that matters is not against a minimal core, because a minimal core is not what you actually deploy. It is against the core plus the plugins you inevitably add: focus trapping for a modal, floating positioning for a menu, persisted state, input masking, transitions. Those are in Summit’s 16KB. In the classic runtimes they are separate downloads, each with its own version and its own way of hooking in.

It also ships TypeScript types, which sounds like a checkbox and is not. Types are how the application programming interface (API) gets discovered without reading docs, both by a person in an editor and by a model with the definitions in context.

What is not in the 16KB matters too. There is no virtual DOM, because the signal graph already knows exactly which nodes to touch, and a diffing layer would be a second, slower source of truth. There is no build step, so there is nothing to configure, nothing to keep current, and nothing a transitive dependency you did not choose can break.

The registration API, and load order

Most of the pain in small frameworks is not the feature set. It is timing: you registered a component after the framework started, or started it before the DOM was ready, and now you are reading changelogs about lifecycle events.

Summit.data("dropdown", (open = false) => ({
  open,
  toggle() { this.open = !this.open },
  init() { /* runs before the component renders */ },
}));

Summit.store("theme", { dark: false, toggle() { this.dark = !this.dark } });

Summit.directive("uppercase", (el, meta, utils) => {
  utils.effect(() => {
    el.textContent = String(utils.evaluate(meta.expression)).toUpperCase();
  });
});

Registration in Summit is timing-safe by construction. Built-ins register at import, your registrations override them, and Summit.start() can be called whenever you like. There is no load-order event to miss, because there is no load-order event.

$watch got the same treatment. It returns an unwatch function, and it fires only on an actual change, so mutating the watched value inside its own callback cannot spin into an infinite loop. That is not a clever feature. It is a bug I did not want to debug twice.

Written to be written by a machine

The stated goal of the project is that an AI agent can write a working, good-looking frontend on the first try. Every decision above serves that, and three serve it directly.

A closed vocabulary. A small fixed set of s- directives and $ magics, with one obvious way to do most things. Models are excellent at reproducing a pattern they have seen and unreliable at choosing between six near-equivalent idioms. Removing the choice removes the failure.

Safety by construction, not by review. Generated markup runs through the interpreter under a strict CSP. The guardrail holds whether or not anyone read the diff, which is the only kind of guardrail worth having.

Discoverable in one fetch. The docs publish llms.txt as an index and llms-full.txt as the entire corpus in Markdown, every page is available as Markdown by appending index.md to its address, and AGENTS.md is a brief you can hand to your own agent. An agent should not have to scrape rendered HTML to learn an API. That is a solved problem that most projects have not bothered to solve.

What it is not

Summit is not a replacement for React on an application with deep client-side routing, server components, or a large team that needs the component model as an organisational boundary as much as a technical one. If your frontend is a real application, use a real application framework.

It is for the very large category of pages that are mostly server-rendered HTML with pockets of behaviour: marketing sites, dashboards, admin surfaces, documentation, internal tools, and anything where the build step costs more than the interactivity is worth. That category is bigger than the industry admits, and it has been paying application-framework prices for years.

The framework is MIT licensed, on npm as summitjs, and one script tag from a content delivery network (CDN) if you would rather skip npm entirely. It is sponsored by NodeMaven. The source is at github.com/velofy/summitjs.

Note, October 2026: Velofy is now an applied AI and security lab, so summit.js is no longer listed on velofy.co or on this site. This post stays up as a record of the design decisions, and the repository is still public.

← All writing