Progressive enhancement
Helm's operator app is built so that every meaningful action works without JavaScript. JavaScript layers on top to make the experience faster and richer — auto-complete, drag-and-drop, in-situ editing — but never as the only path. This is the "progressive enhancement" doctrine, applied as a UX principle, a reliability principle, and an accessibility principle simultaneously.
The shortest version
Forms post to URLs. URLs return rendered HTML. Buttons are buttons. Links go places. JavaScript intercepts these to do them faster (in-place updates, no page reload) — but if JavaScript fails to load or breaks, the form still submits, the link still navigates, the button still works.
Why this matters in a bike shop
Three reasons specific to Helm's environment:
Bike shops have slow networks sometimes. Saturday-morning Wi-Fi competing with 30 customers' phones in a 1000-square-foot shop. A page that needs 200KB of JS to render anything useful is unusable until that JS lands. A page that's already useful HTML, then enhanced when JS arrives, is usable immediately.
Browsers run things you don't predict. Operator pulls up the till on an old Surface tablet from 2018 with a stale Edge build. New Object.hasOwn-style code might error out before the app initializes. Progressive enhancement means the static HTML is still functional even when the JS bundle blows up.
Reliability beats polish. A receipt that prints because the form submitted is better than a receipt that didn't print because the React tree threw. Polish matters when reliability is solved, not before.
What this looks like in code
Forms
Every form that mutates server state has a real action and method:
<form action="/api/customers/4521" method="POST" data-enhance="customer-update">
<input name="_method" value="PUT">
<input name="phone" value="250-555-1234">
<button type="submit">Save</button>
</form>
The Worker handles the form POST exactly as it handles a JSON API call (it sniffs Content-Type). Without JS, the browser submits the form, the Worker returns an HTML page that includes the new state, the user sees the change. With JS, an enhancer in public/js/enhance.js intercepts submit, sends a JSON request, updates the DOM in place, no reload.
Links
Internal navigation is <a href> links that go to real pages. The SPA-style router intercepts clicks and avoids the reload, but if JS fails or the user middle-clicks, the link works.
<a href="/customers/4521">Jane Doe</a>
Tables and lists
Server-rendered HTML tables, with sort/filter handled by URL query params:
<a href="/customers?sort=last_name&page=2">Next</a>
JS can intercept and re-render in place; the URL is the source of truth either way.
In-situ editing
The helm-editable chassis (see in-situ editing) is JS-only by definition — direct manipulation requires JS. But the things you'd edit in-situ are also editable through the Settings page, which is a server-rendered HTML form. JS gives you faster paths; HTML gives you the slow path.
What this looks like in the data flow
For a typical CRUD endpoint, the Worker checks the request Accept header:
// Pseudo
if (request.headers.get('Accept').includes('application/json')) {
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' }});
}
return new Response(renderHtml(data), { headers: { 'Content-Type': 'text/html' }});
The same code path serves both. The HTML rendering uses small server-side templates (vanilla string templates or a tiny templating helper). No SSR React, no Astro — too much machinery.
What we accept losing
Some patterns are not progressively-enhanceable in any reasonable form. We accept the constraint.
- Real-time kanban board updates. When Robbie drags a ticket, the page does need JS to re-render without a reload. Without JS, the operator sees a server-rendered list view of tickets sorted by status with simple status-change
<form>s. - AI Support bubble. No JS, no chat UI. The bubble is a JS-only feature. Without JS, the operator gets a "Try the AI Support bubble" CTA that goes to a server-rendered form.
- Drag-and-drop in-situ editing. Same as above. The server-rendered fallback is the Settings page.
- Auto-save on every keystroke. Without JS, the operator presses Save explicitly. Acceptable degradation.
What this means for new features
When designing a new feature, ask: what's the no-JS version? If the answer is "there isn't one," is the feature genuinely impossible without JS (real-time, drag-drop, streaming AI responses), or is it just easier with JS? In the latter case, build the no-JS version first; layer JS on second.
This rule prevents the slow accumulation of "features that quietly require JS." It keeps the app working under adverse conditions, which is the entire point of building a bike-shop POS instead of a tech-startup MVP.
Performance budget tied to this
Because the base HTML renders without JS, the JS bundle can be small. We budget:
- Initial HTML response: < 50KB compressed
- Critical JS (in-situ editing + nav enhancer): < 30KB compressed, deferred load
- Per-screen JS extras: lazy-loaded, < 20KB each
- No JS framework runtime (no React, no Vue) — saves ~50-150KB for the parts that don't need it
A POS that loads in under 1 second on a slow connection beats a POS that loads in under 2 seconds. The 1-second figure is what progressive enhancement enables.
See also
- Tech stack summary — why no React framework
- ADR-0008: Slice-by-slice build — same shipping discipline applies to this principle
- In-situ editing — the JS-only counterpart that lives within this discipline
- Code style — the conventions that keep this practical