HR-ATS-Portal/docs/architecture/adr/0013-frontend-strangler-mig...

21 KiB
Raw Blame History

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

  1. Security. No HTML escaping exists anywhere in the repository — grep for escapeHtml, sanitiz, DOMPurify returns 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.

  2. 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 on window is how a two-person team stalls in month four.

  3. The candidate/application split will churn every entity shape on screen. The prototype's flat candidate carries jobId, jobTitle, stage, aiScore, recruiter directly (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 by tsc.


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 shared window state 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.md are 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.css by git mv with 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-list on 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 bannedreact/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":

  1. 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.
  2. The prototype is frozen after the ADR 0014 hardening pass, except for security fixes.
  3. Each migrated screen deletes its prototype counterpart in the same PR. Not in a follow-up ticket. The same PR.
  4. 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 2030 developer-days spread across Phases 14 to port 23 screens, on top of building the screens that do not exist yet. Confidence: medium for waves 12 (the shell and the four data screens are well understood), low for waves 34 (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 612 month period with two frontends in one repository, and 2030 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-danger as 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.js keeps 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 612 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.
  • 2030 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 LowMedium 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 LowMedium 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 34, 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

  • 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-linter rather 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 and tsc --noEmit binding rather than aspirational.
  • ADR 0012 — deployment: the built bundle is served as static files by the web process 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.md Part 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).