21 KiB
ADR 0013 — Frontend strangler migration to Vite + TypeScript + React, with the design system frozen verbatim
| Status | Accepted — 2026-07-30 |
| Scope | What happens to the existing browser-only prototype: which parts are preserved unchanged, which are replaced, in what order, and what the coexistence rules are while both exist |
| Owner | Talha Ahmed owns the build toolchain, the API client generation and the migration order. Ahmed Mujtaba owns the port of the js/ui.js primitives to typed components, the stylelint token rule, the Zod schema layer and the Vitest component suite — each independently demonstrable, each with a Talha review checkpoint |
| Consistent with | _decisions.md Part 1 → Frontend: retain, harden, migrate or rebuild; Immediate XSS hardening of the prototype (ADR 0014, the separate immediate step); Testing, CI and delivery process (react/no-danger, stylelint, tsc --noEmit, Vitest, Playwright) |
| Related ADRs | 0014 (the Phase 0 hardening this decision deliberately does not wait for), 0015 (the backend analogue — boundaries enforced by a tool, not by discipline), 0016 (the CI that makes both enforceable), 0012 (the built bundle is served as static files by the web process behind the platform CDN) |
Context
This is the one decision in the package where the repository is not greenfield. Everything else — backend, database, queue, auth, CI — is chosen from nothing (_repo-findings.md §B). Here there is 7,400 lines of real, working, verified work, and the honest answer is that the two halves of it deserve opposite verdicts.
What exists, split by verdict
| Asset | Size / evidence | Verdict |
|---|---|---|
css/styles.css — tokenised design system, dual light/dark themes, Utopia brand palette and type hierarchy, responsive 320px → ultrawide, WCAG 2.1 AA verified across 23 routes × 2 themes (8,459 text nodes, 0 failures), 44px touch targets, safe-area and dvh handling |
1,269 lines, 93 design tokens across 159 custom-property declarations and 470 var(--…) references (measured, 01 §12; supersedes the "424" figure) (_repo-findings.md §G) |
Preserve verbatim. Expensive, verified, brand-compliant, and portable because it uses semantic class names (.card, .dt, .badge) not utility classes |
js/charts.js — dependency-free canvas engine: line/area, bar, grouped bar, doughnut, horizontal bar, sparkline, reading colours from CSS custom properties so it re-themes automatically |
347 lines (js/charts.js:339 export list) |
Retain as-is behind one thin wrapper |
js/ui.js primitives — modal, toast, dataTable, badge, avatar, avatarStack, scoreChip, pbar, fieldError, clearErrors, icon set |
251 lines (js/ui.js:251) |
Port one-for-one, keeping the same class names so the CSS keeps matching |
| The 23-route information architecture — module breakdown, navigation grouping, screen inventory | js/app.js:7-16 |
Preserve as the route table and the Phase 1+ screen backlog |
The rendering layer — 34 innerHTML assignments across 14 files, no HTML escaping anywhere, inline handlers with interpolated ids, 22 ordered <script> tags, everything on window, no modules, no types, no tests |
_repo-findings.md §E, §H; index.html:264-285; js/candidates.js:68,121 |
Replace. This is the liability |
The three forces that decide it
-
Security. No HTML escaping exists anywhere in the repository —
grepforescapeHtml,sanitiz,DOMPurifyreturns nothing (_repo-findings.md§E). Candidate-controlled values are interpolated raw into markup (js/candidates.js:68). Today nothing is exploitable because the data is a locally seeded LCG (js/data.js:8-10). But the two primary Phase 1 intake sources are CV files and inbound email, both attacker-supplied by design. A CV with<img src=x onerror=…>in its name field executes in a recruiter session with full application privileges. In a JSX renderer this class of bug is structurally impossible by default; in the current renderer it is a per-line discipline that must hold across 34 sites forever. -
The forms that do not exist yet are the hardest ones in the product. Versioned requisitions with weighted requirements whose weights must sum to 1.0, interview scorecards, offer approval chains, the duplicate-review and merge-undo screens. There are already ~115
<input>/<select>/<textarea>sites spread across 15 files with ad-hoc per-form validation (js/offers.js:129,js/ui.js:241-249). Building the remaining forms as template strings onwindowis how a two-person team stalls in month four. -
The candidate/application split will churn every entity shape on screen. The prototype's flat candidate carries
jobId,jobTitle,stage,aiScore,recruiterdirectly (js/data.js:117-127). Phase 1 splits candidate identity from applications and makes scores per-application. Every screen touching a candidate changes shape. Untyped, that churn is caught at runtime by a recruiter; typed, it is caught bytsc.
Options considered
Option A — Retain the prototype as-is, harden it, and build forward in it
Keep innerHTML templating, add UI.esc(), keep going.
- For: zero migration cost, no build step (which the repo deliberately lacks, §B), one language, immediate feature velocity, the junior is already productive in it.
- Against: the XSS guarantee stays a per-line discipline across every new interpolation for the platform's whole life; no modules, no types, no component reuse, no test seam; the complex forms above land in the worst possible substrate; a 25-module product on 22 ordered
<script>tags and sharedwindowstate is the same ball-of-mud failure the boundary ADR (0015) exists to prevent on the backend. - Verdict: rejected as a destination. Adopted as the immediate step — that is ADR 0014, and it is deliberately a separate decision so the security deadline does not depend on this one.
Option B — Rebuild everything, including the CSS
- For: one coherent codebase, no coexistence period, freedom to pick any styling approach.
- Against: discards the single most valuable verified asset in the repository. 1,269 lines of dual-theme, AA-verified, brand-compliant CSS is weeks of work to reproduce with a real chance of regressing accessibility — and accessibility regressions are invisible until someone is excluded. It also blocks backend progress while the frontend is rebuilt from zero.
- Verdict: rejected. This is the option that looks decisive and is actually just expensive.
Option C — Django templates + HTMX
Server-rendered, autoescaping by default, no build step, one language across the stack.
- For: genuinely strong on this brief. Autoescaping kills §E structurally with no migration. No build step, no second language, no bundle. Cheaper per CRUD screen than React. Matches the Django choice in ADR 0018.
- Against: the product's centre of gravity is interactive, not document-shaped — a drag-and-drop kanban across 7 stages, a streaming chatbot dock available on every screen, canvas charts that re-render on filter change, live score updates. Each of those is a fight in HTMX and a non-event in React. It also strands
js/charts.js, which is imperative canvas code expecting to own a DOM node. - Verdict: rejected, but it is the closest runner-up and the tradeoff is real. If the assistant dock and the kanban were dropped from scope, this would win.
Option D — Svelte (or SolidJS) instead of React
- For: less code than React for the same screens, no virtual DOM, smaller bundle, and its escaping story is identical.
- Against: decided on team shape rather than technical merit. The junior's stream must be small, varied and independently demonstrable, which means both developers and the AI tooling they use need to be able to help with any given task. React's ubiquity is the deciding property. React Hook Form + Zod also gives the junior a validation workstream that mirrors the DRF serializers conceptually, which is a deliberate pedagogical choice.
- Verdict: rejected on ecosystem depth, not on quality. Stated plainly because "React by default" is exactly the reasoning this package is supposed to avoid, and this is not that — it is React because the reviewer bottleneck is one person.
Option E — Big-bang cutover to React (rewrite all 23 screens, then switch)
- For: no coexistence period, no drift risk, one frontend at all times.
- Against: months with nothing demonstrable, and it front-loads all frontend work before any backend exists to talk to. The phase boundaries in
_decisions.mdare drawn so each phase ends with something showable to the Talent Lead; a big-bang frontend breaks that. - Verdict: rejected. Strangler, screen by screen.
Decision
Progressive strangler migration to Vite + TypeScript + React 18, with the design system preserved verbatim. Three commitments, in descending order of how strictly they bind:
1. css/styles.css is content-frozen and becomes the design contract
- It moves to
web/src/styles/styles.cssbygit mvwith zero content edits. The diff of that commit must show a pure rename. - New component CSS may only use existing
var(--…)tokens. Enforced by a stylelint rule (declaration-property-value-allowed-liston colour, spacing, radius and font properties) so a hardcoded hex is a failed build, not a review comment. - Changing a token value is a design decision requiring the brand guide, not a frontend task. Adding a token is allowed; redefining one is not.
- Why it is frozen rather than merely reused: the AA verification is a property of the whole file across 23 routes × 2 themes. Any edit invalidates the verification and nobody is going to re-run 8,459 contrast checks by hand.
2. js/charts.js is retained as-is behind one wrapper
One <Chart/> component takes a canvas ref, a chart type and a data payload, and calls into the existing engine on mount and on data change. The engine already reads colours from CSS custom properties (js/charts.js:9), so it re-themes itself with no React involvement at all. Rewriting it in a charting library would add a dependency, lose the automatic theming and produce no user-visible improvement — pure loss.
3. The rendering layer is rebuilt, screen by screen
| Concern | Choice | Replaces |
|---|---|---|
| Build and dev server | Vite | 22 ordered <script> tags (index.html:264-285) |
| Language | TypeScript, tsc --noEmit in CI |
untyped window globals |
| Components | React 18 | view functions returning HTML strings plus an onMount hook (js/app.js:20-57) |
| Routing | React Router over the same 23-route table | the location.hash router (js/app.js:7-16) |
| Server state | TanStack Query | the in-memory DB global (js/data.js) |
| Forms | React Hook Form + Zod | ~115 ad-hoc input sites, per-form checks (js/offers.js:129) |
| Tables | TanStack Table against server-side DRF pagination | UI.dataTable's client-side sort/paginate |
| API client | Generated from the drf-spectacular OpenAPI schema | nothing — there are zero network calls today (_repo-findings.md §C) |
| Charts | <Chart/> wrapper over the retained engine |
— (retained) |
| Tests | Vitest for components, Playwright for the 5 smoke journeys | nothing (§B) |
The 10 primitives at js/ui.js:251 are ported one-for-one, keeping the same class names, so the frozen CSS keeps matching without a single selector change. This is the junior's first substantial React workstream and it is deliberately shaped as ten small independently demonstrable pieces.
dangerouslySetInnerHTML is banned — react/no-danger as an ESLint error in CI, not a warning. There is no legitimate use for it in this product; the one plausible candidate (rendering a rich-text job description) goes through a server-side sanitised-HTML pipeline or is stored as structured blocks, decided when that screen is built.
Migration order — untrusted data first
| Wave | Screens | Why here |
|---|---|---|
| 1 | Shell, navigation, login | Nothing else can be migrated until the shell, auth context and API client exist |
| 2 | Inbox, CV Import, Candidates, candidate profile | These are the screens that will render attacker-supplied data first. They migrate before real intake is switched on, so §E never has a live window in React |
| 3 | Requisitions, Pipeline board, Interviews, Offers | The heavy forms and the state-machine UI. These do not exist in usable form today, so they are built in React rather than migrated |
| 4 | Analytics, Reports, Settings, Help, remaining read-mostly surfaces | Lowest risk, least churn, and they benefit most from the retained chart engine |
The ordering is a security ordering, not a difficulty ordering. Wave 2 is not the easiest wave; it is the one that must not be last.
Coexistence rules while both frontends exist
These four rules are the entire answer to "two frontends invite drift":
- Only the React app is ever wired to real data. The prototype stays pointed at its seeded LCG generator. This is written down as a rule because the failure mode is a well-intentioned demo.
- The prototype is frozen after the ADR 0014 hardening pass, except for security fixes.
- Each migrated screen deletes its prototype counterpart in the same PR. Not in a follow-up ticket. The same PR.
- The prototype is never deployed to production. It is a demo and reference artefact served locally by
devserver.py(36 lines, added for preview only).
Justification
The asymmetry is the whole argument. A single verdict on "the frontend" would be wrong in one direction or the other: retain-everything keeps a P0 security posture as a permanent discipline, rebuild-everything throws away the most verified artefact in the repository. Splitting the verdict along the CSS/rendering seam costs one thing — a coexistence period — and that cost is bounded by rule 3 above.
On the added build step. The repository deliberately has no build tooling (§B) and this decision adds it. That is a real loss: python3 devserver.py and a browser reload is a genuinely good developer experience. It is accepted because the three things the build step buys are all things this product specifically needs — JSX escaping (security), type checking across an entity model that is about to change shape (correctness), and a module system for 25 modules of UI (maintainability). If the product were 5 screens of read-only dashboards, this decision would go the other way.
On cost, stated as a range not a number. Roughly 20–30 developer-days spread across Phases 1–4 to port 23 screens, on top of building the screens that do not exist yet. Confidence: medium for waves 1–2 (the shell and the four data screens are well understood), low for waves 3–4 (those screens are new work, so "porting" is the wrong verb and the estimate is really a build estimate). This does not sit on the critical path as a single block; it is interleaved per phase, which is the point of a strangler.
The tradeoff, stated plainly
We are accepting a build step, a second language, a 6–12 month period with two frontends in one repository, and 20–30 developer-days of porting, in exchange for making the §E stored-XSS class structurally impossible instead of procedurally avoided, catching the candidate/application entity churn at compile time, and getting a test seam where there is currently none. With attacker-supplied CVs as a product requirement, the security half of that trade alone carries the decision.
Consequences
Positive
- Stored XSS in the rendering layer becomes structurally impossible rather than a 34-site discipline.
react/no-dangeras a CI error is the enforcement. - The verified accessibility work survives intact — the frozen CSS with unchanged class names means AA compliance is inherited, not re-earned.
js/charts.jskeeps its automatic theming for free; no charting dependency enters the project.- TypeScript catches the entity-shape churn from the candidate/application split at build time.
- Components are testable (Vitest + Playwright) where the current view functions are not.
- The junior gets ten small, visible, independently demonstrable ports as a first React workstream, then a Zod validation stream that mirrors the DRF serializers.
- The 23-route IA is preserved as the route table, so no UX decision is relitigated.
Negative — the costs being accepted
- A build step now exists.
npm install, a dev server, a bundle, lockfile churn and dependency upgrades — none of which the repository has today. - Two frontends coexist for 6–12 months. Mitigated by the four coexistence rules, and rule 3 (delete in the same PR) is the one that actually prevents drift.
- A second language and a second toolchain for a two-person team. Mitigated by generating the TS API client from the OpenAPI schema so the contract is written once, in Python.
- 20–30 developer-days of porting that produce zero new user-visible capability.
- Bundle size and a client-side rendering cost that the current zero-dependency prototype does not pay.
- The frozen CSS constrains component structure. New components must fit existing semantic class names, which occasionally means slightly awkward markup rather than a new token. Accepted deliberately: the constraint is what keeps the design contract meaningful.
Risks
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | Someone points the hardened prototype at real mail or real CVs to demo it, before wave 2 lands | Medium — this is the realistic failure, not a theoretical one | High — stored XSS in a recruiter session | Rule 1 written down; ADR 0014's CSP without unsafe-inline and the CI grep gate; the prototype is never deployed to production |
| R2 | The migration slips and wave 2 is still unmigrated when Phase 1 intake goes live | Medium | High | ADR 0014 exists precisely so the security guarantee does not depend on this schedule. Wave 2 is ordered first among screens for the same reason |
| R3 | A fix is applied to a prototype screen and not to its React replacement, or vice versa | Medium | Low–Medium | Rules 2 and 3: the prototype is frozen, and the counterpart is deleted in the migrating PR so there is nothing to diverge |
| R4 | Someone edits css/styles.css "just slightly" and silently invalidates the AA verification |
Medium | Medium | The file is content-frozen; the stylelint token rule catches new hardcoded values; a CHECKS-style note at the top of the file and a CODEOWNERS entry make the edit visible in review |
| R5 | Ported primitives drift from the CSS class names, breaking styling in ways that look like CSS bugs | Low–Medium | Low | Ports are one-for-one with the same class names, and each port is reviewed by Talha against the original |
| R6 | React and TypeScript prove to be more ceremony than the junior can absorb alongside backend work | Low | Medium | The ten primitive ports are deliberately the first task — small, visually verifiable, and each one either works or obviously does not |
Revisit conditions
Reopen this decision if any of the following becomes true:
| # | Condition | Expected move |
|---|---|---|
| T1 | The assistant dock and the drag-and-drop pipeline board are both dropped from scope | Django templates + HTMX (Option C) becomes the better answer. Revisit before wave 3 |
| T2 | Wave 2 has not landed by the time real intake is switched on | Do not proceed with real data. The gate is ADR 0014's hardening plus a written decision, not a schedule slip |
| T3 | The port exceeds 45 developer-days measured, i.e. 1.5× the top of the range | Stop porting waves 3–4, keep the hardened prototype for the read-mostly surfaces indefinitely, and accept a permanent two-frontend split for low-risk screens |
| T4 | Bundle size or client render time becomes a measured complaint from users on the 66 seats | Route-level code splitting first; server-rendering the read-mostly surfaces second. Not a framework change |
| T5 | A third developer joins | Revisit T3 — the porting constraint is reviewer capacity, not technical difficulty |
Related
- ADR 0014 — the Phase 0 XSS/CSP hardening of the prototype. Deliberately a separate decision so the security deadline is independent of this migration's schedule.
- ADR 0015 — the backend analogue: module boundaries enforced by
import-linterrather than by convention. Same reasoning, different layer — with one reviewer, discipline does not scale, so the rule must be a tool. - ADR 0016 — the CI pipeline that makes
react/no-danger, stylelint token enforcement andtsc --noEmitbinding rather than aspirational. - ADR 0012 — deployment: the built bundle is served as static files by the
webprocess behind the platform CDN, and a bad frontend rolls back by redeploying the previous content-hashed bundle. - ADR 0006 §"Output escaping is in scope for this decision" — search results are a rendering surface for candidate-controlled text and are covered by the same guarantee.
_decisions.mdPart 1 → Frontend: retain, harden, migrate or rebuild; Phasing… (the junior's task stream)._repo-findings.md§E (the XSS finding), §G (the assets retained), §H (the liabilities replaced).