0025: The web surface moves to React + TypeScript + Fluent UI 2, built at dev time and committed like the current UI
- Status: Accepted (Navjyot, 2026-10-06; proposed as a spike 2026-09-29)
- Date: 2026-09-29
- Related: 0024 (the local-auth contract this change does not touch)
Contextβ
orion web's front end has been hand-built four times over on the same
stack β vendored Preact 10.24.3 + htm 3.1.1, plain CSS custom properties, no
build step β and each attempt at an
enterprise-grade look has fallen short of what was asked for. The pattern is
not a one-off miss: every corporate-feeling component (data table, tabs,
drawer, command palette, theming, focus management, accessibility) had to be
hand-built in raw CSS and class-component Preact, with no library backing
any of it. Effort went into the attempt; the result did not close the gap.
Separately, OR-492's re-tune (2026-09-26 β 2026-09-29) found the current UI
already drifted from internal/ui's own vocabulary after the orion watch
redesign (see [B9] OR-572) β a second data point that a hand-maintained
parallel implementation keeps falling behind its source of truth.
Hard requirement carried into this decision, non-negotiable: orion web
must keep starting exactly as it does today β one binary, one command, no
Docker, no separate server process, no Node or npm needed to run it.
Whatever frontend approach is chosen, this does not change.
Decisionβ
Rebuild the web surface on React 19 + TypeScript + Fluent UI 2
(@fluentui/react-components), built with Vite, with the build output
committed to the repository and served the same way static/ is today.
- New source tree:
internal/web/ui-next/β a normal Vite + React + TS app, npm-managed, with its ownnode_modules/(gitignored) andpackage.json. npm run buildwrites straight intointernal/web/static/next/(vite.config.ts'soutDir), which is committed, not gitignored.internal/web/assets_next.goembedsstatic/nextvia//go:embed, exactly mirroringassets.go's reasoning for whystatic/itself is committed: a//go:embedpattern matching nothing is a build error, not an empty directory, so the compiled-in tree must exist in the repository before anyone has ever run the front-end build on that checkout.- Served at
/next/, alongside the existing UI at/. Nothing about the current route,static/, or any existing handler changes. - Node and npm are needed only to edit the new UI (
npm run dev,npm run build) β never to runorion web.go build ./...andorion webboth work identically on a machine with no Node installed, because the compiled-in tree is what ships, not the source.
This was verified directly during the spike: go build ./... was
run with PATH stripped of every Node/npm/nvm/homebrew entry and still
produced a working orion binary that served both / and /next/ with
live /api/snapshot data, in both themes, at 1512px and 390px, with zero
console errors and zero overflowing elements.
Alternatives consideredβ
- Stay on Preact + htm + plain CSS, invest more effort. Rejected: this is the fourth attempt on that stack, not the first, and the pattern is that hand-building each corporate-feeling primitive (data table, tabs, drawer, palette, theming, focus/a11y) from raw CSS does not converge on an enterprise look no matter how much CSS is written. The problem is the approach, not the effort spent on it.
- IBM Carbon. Strong data-grid and dashboard components, genuinely enterprise/analytical in feel. Passed over in favour of Fluent UI 2 as the team's stated preference (2026-09-29); revisit if Fluent's component set proves insufficient for a later story (e.g. [S37] OR-537's data table).
- Atlassian Design System. Familiar to Jira users, which fits this project's own tracker. Passed over: some packages are less open to use outside Atlassian's own products, and licensing needs checking before any commitment.
- shadcn/ui + Tailwind. Modern and fully owned (components are copied into the repo, not a dependency), but reads as a SaaS product rather than an enterprise/IT-department one, and every component still needs individual polish β closer to the current hand-built problem than a clean escape from it.
- A CSS-only vendored design system (no React), staying on Preact. Would keep the no-build-step property but still leaves every interactive component (tabs, command palette, focus trap, live regions) hand-wired in Preact class components with no framework support for any of it. Rejected for the same reason as "invest more effort" above β the primitives are the gap, not just the visual layer.
Consequencesβ
- Node becomes a required tool for anyone editing
internal/web/ui-next/. It was never required to runorion web, and still is not β this ADR does not change that. CI and any packaging job need Node only to rebuildstatic/next/when the UI source changes, not to build or run theorionbinary itself. - A stale committed build is now a real failure mode. If
ui-next/srcchanges withoutstatic/next/being rebuilt in the same commit, the binary keeps serving old JavaScript with no error β the same riskassets.goalready carries forstatic/, and a CI check should assertgit diff --exit-code internal/web/static/nextafter a cleannpm run build, the same way a stale-build check would be added for the existing UI if one does not exist already. - The "hand-written source only" guard now has one exception.
TestBuildHasNoFrontEndToolchainOutputInStaticrejects any tracked file understatic/with a line over 400 characters, to stop bundler output entering the old UI's directory. A minified React build is exactly that, so the test skipsstatic/next/and nothing else. Everything outside it is still checked. Accepting this ADR accepts that narrowing. - Two front ends coexist during the transition.
/(Preact/htm) keeps working unmodified while/next/(React/Fluent) is built out story by story under OR-492. The cutover point (retiring/and moving/next/to/) is a separate decision, made once/next/covers what/covers today β not implied by this ADR. - OR-492's stories need re-planning onto this stack once accepted. Roughly eight stories (themes, type scale, nav, command palette, the history data table, accessibility) shrink or fold into "use the library's version" rather than "hand-build it"; the data and watch-parity stories (OR-572βOR-576, S24βS28, S38βS41) are unaffected β they are server-side or data-shape work, not rendering.
- The vendored Preact + htm runtime (
internal/web/ui/vendor/,VENDOR.md) is not removed by this ADR. It stays in place as long as/is served from it. Its removal is a follow-on decision for whenever the cutover happens, not this one. - This does not touch 0024.
/next/is registeredHandleReadOnly, GET/HEAD only, exactly like/and/vendor/β no new write endpoint, no change to the token/Origin/Host middleware.
Status of this decisionβ
Proposed. OR-577's spike (Overview page only, against live
/api/snapshot data, reviewed in both themes and at 390px/1512px) is
built to let this be judged against a real page rather than a description.
Accepting this ADR means re-planning OR-492's remaining stories onto this
stack; rejecting it means reverting to the Preact/htm approach and closing
OR-577 without adopting its output.