Deleting 21,000 lines: one UI for two apps, then a SaaS in a night
Since mid-February, PlotDevice has been two apps: the Electron original and a web port. They shared a database schema, a feature list, and nothing else: 118 files and 21,793 lines in the Electron renderer, 122 files and 22,437 lines in the web client, near-identical, kept in sync by a rule in the project's CLAUDE.md that read, in bold, "always apply the same change to both versions".
A sync rule enforced by prose is a sync rule enforced by hope. Every feature for a month has landed twice; every fix has had a twin that sometimes arrived a day late. This week the duplication died: one commit, 270 files, and a net 21,500 lines deleted.
The unification
The mechanics are almost embarrassingly small. Components, stores,
hooks, and lib moved to a shared src/ui/; types, constants, and
prompts to src/shared/. Each app keeps a thin entry (the Electron
side shrank to 6 files and 282 lines, the web client to 10 and 952),
and the platform boundary is one file, quoted here in its entirety:
import type { AppAPI } from '@shared/api-types'
let _api: AppAPI
export function setApi(api: AppAPI) { _api = api }
export function getApi(): AppAPI { return _api }
Shared code calls getApi(); each entry point calls setApi() at
startup with its own client: IPC bridge in Electron, HTTP in the web
app. That's the whole abstraction. No dependency injection framework,
no adapter hierarchy: a module-level variable and a type.
Three hooks deliberately stayed platform-specific (streaming, refine, and the LLM debug feed), because they genuinely differ in transport (IPC events versus SSE), and pretending otherwise is how dishonest interfaces get written. I've refused that abstraction before, in another project, for four chat platforms; the rule generalises: share what's identical, duplicate what isn't, and never average.
The part that made it possible at all: a spec from mid-February
(02_web_electron_plan.md) had already chosen "Option C — Shared
Component Library, ~95% code reuse" as the destination. The month of
double-landing features wasn't the plan failing; it was the toll paid
while the codebase earned enough stability to be worth unifying.
Then, the SaaS layer
With one UI to serve, the web app grew its production skin in a single night commit: bcrypt and JWTs (15-minute access, 7-day refresh in an httpOnly cookie), email verification, Google OAuth, GDPR export and consent tables, Stripe subscriptions with webhooks, per-plan LLM metering, rate limits, a Caddy reverse proxy, and a deploy pipeline. Free tier: 100k input tokens and 2 books. Pro at $12, Premium at $24 with the desktop app included.
Two design points I'd defend. Metering short-circuits for BYOK:
bring your own API key and the quota system steps aside entirely,
because charging margin on tokens you didn't pay for is a business
model with a half-life. And over-limit responses return
{ upgrade: true, currentPlan } rather than a bare 429 — the error is
the pricing page.
Three reviews, thirteen bugs, zero humans
Here's the part that belongs on this blog: the SaaS commit was followed by three fix commits in 24 hours, each the output of an AI code review pass over the previous work. The bugs were not hypothetical:
- The billing endpoint fetched the user's first usage row and post-filtered it. Any prior month's row masked the current one. Classic query-shaped-like-a-filter.
- The Electron app read
data.period_endfrom an API that returnscurrentPeriodEnd. Forever undefined, silently. - The review endpoints — the single most expensive AI path in the product — had no usage check at all. The meter was installed on every door except the vault.
- The Stripe SDK's pinned
apiVersiondidn't match the installed library, and that version movedcurrent_period_endintosubscription.items. Two lies, one invoice. - A
PrivacySettingscomponent was written, exported, and never mounted. GDPR compliance as dead code. useState(() => {...})used as a mount effect: the initializer ran, the intent didn't.
None of these would have survived a week in production unnoticed; all of them survived the author. The review passes cost minutes. I keep relearning this from both sides: the same model that writes the bug at generation time finds it at review time, because the two tasks point its attention in different directions. The economics are the real story: when a full review pass costs minutes, review stops being a gate you ration and becomes a loop you run.
The third review round also fixed the fix-generator. The AI-punctuation prompt was flagging single em-dashes and deliberate asyndeton, so it got the two clauses every over-eager reviewer needs: "standard English punctuation" is not a finding.
The bug JSON deserved
One more, because it's the most novel-writing-specific bug I've ever
shipped: LLM review responses are JSON, and fiction contains dialogue,
and dialogue contains quotation marks, and models sometimes forget to
escape them inside JSON strings. Result: perfectly good findings,
unparseable. The fix, safeJsonParse, walks the string and decides
each interior " by lookahead: structural if what follows is
,/]/}/:, otherwise escape it and keep reading. Heuristic,
imperfect, and it turned a daily parse failure into a non-event. It
went into the Electron backend first and was mirrored to the web
review route a day later — the last echo of the duplication this week
mostly killed, still audible in exactly the two files that remain
hand-synced.
Honest ledger
- Token metering estimates
chars / 4rather than reading provider usage numbers. The meter is a sundial. - Review endpoints now check quota but still don't record usage: spend is gated, not counted.
- CI runs
tsc --noEmitand nothing else. The 130-test suite from day one doesn't run on push yet, which is a sentence I typed slowly. - The image migration off base64 is written and not yet run, so every cover is currently stored twice-ish.
The week's arithmetic: minus 21,500 duplicated lines, plus one five-line abstraction, plus a billing system, minus thirteen bugs I didn't have to find myself. The CLAUDE.md sync rule is deleted — the only sync rule that ever works is the file system.