SpecPulse: An Electron + React + Vite Workbench That Turns Natural Language into a Declarative UI Spec and Compiles It into Real React Components with Live Preview

🇨🇳 中文版

The Starting Point

I initially thought the problem with AI UI generation was "how to make the LLM write better React."

Later I realized the real problem is precisely the opposite: you shouldn't let the LLM write React directly.

SpecPulse ended up taking a different path:

Natural Language → LLM → UISpec → Deterministic Generator → React

SpecPulse is the hands-on project I built to validate that path: describe a requirement in natural language, an LLM turns it into a declarative UI spec (a JSON tree), and a deterministic generator compiles it into real React components. It started from a very concrete frustration — I needed to spin up an internal tool page quickly, and the path was describe requirements → wait for design → wait for scheduling → hand-write components → debug styles, back and forth. I wondered if I could skip those middle steps and turn natural language directly into a working React page. Conceptually it's simple:

prompt ──► LLM ──► UI spec (JSON tree) ──► React component ──► Vite preview
         (src/agent)   (src/spec)        (src/generator)       (preview/)

The first version had only four files — so simple it was almost crude. A single createAgent() in src/agent/agent.ts threw the prompt at the LLM, the LLM returned a JSON blob, and a specToComponent(spec) in src/generator/reactGenerator.ts translated it into JSX source, written to preview/src/App.tsx; Vite hot-reloaded, and there it was in the browser. No Electron, no IPC, no history, no adjustment box, no edit mode. Just one command:

npm run build -- "a landing page for a cloud storage startup with navbar, hero heading, feature cards and a CTA button"

Run it, open preview/, done.

Only after this minimal loop worked did I realize the real hard part wasn't "generation" but "iteration." The first generation is basically always wrong — the layout is too monotonous, the wrong components get used, the colors are absurd. Users aren't satisfied just because you generated a first version; they'll say "make the title blue," "add a Stat card under the Hero," "put the three feature cards side by side."

So I added src/cli/adjust.ts. Instead of generating from scratch, it first reads the existing record's spec.json, expands references like #1.2 in the prompt into concrete node descriptions, lets the LLM output the diffed full spec, then recompiles with specToComponent. When I first implemented it I hit a snag: the LLM would often "helpfully" change parts the user never asked to touch. So I hard-coded "only change what the user asked for, keep the rest as-is" into adjust_system_prompt — but the LLM doesn't always obey, so later I added a check in collectTypes that warns if more than half the component types are lost after adjustment.

Next came edit mode. The adjustment box depends on the LLM, calling the API every time — slow and expensive. I wanted a purely local edit path: click a component in the preview, change fields directly in the Inspector, and on Save, skip the LLM entirely and just run specToComponent once to recompile. That capability now lives in preview/src/PreviewRoot.tsx — it receives the spec from the Electron main process via postMessage, swaps the compiled App for a SpecEditor, and on save writes back to spec.json then calls src/cli/regenerate.ts.

History was added later. In the earliest version, generating overwrote App.tsx and the previous version was just gone. The archive() function came later: before each build or adjust, it packages the current state into generated/<stamp>-<title>/, where stamp is the formatted current time and safeTitle replaces illegal characters. I chose timestamps for directory names to avoid introducing an extra database or ID-generation logic — a simple string concatenation solved it; the cost is that if two generations land in the same second, the directories collide and users can't tell which is which. prompt.txt keeps the original prompt, spec.json keeps the intermediate representation, App.tsx keeps the compiled artifact — all three are essential, but spec.json alone can regenerate everything, so the generator was deliberately designed as a pure function.

The Export feature came from another scenario: I wanted to send a generated result to someone else without exposing my prompt — the prompt might contain internal project names or sensitive business info. src/cli/export.ts inlines the spec into the HTML's window.__UIAGENT_SPEC__, inlines the CSS and JS too, and produces a single-file index.html, leaving prompt.txt out of the package. To redeploy, you just swap the spec.json and the page changes — no rebuild needed.

Looking back, this project evolved from a simple "turn text into a page" experiment into the current Electron + React + Vite workbench, and every step came from hitting a problem the first loop couldn't solve — iteration, local editing, history, safe export. I didn't draw an architecture diagram upfront; I started with one command, one function, one preview window, and patched in each piece one at a time.

Still, there was a fundamental trade-off in the first version: I treated the LLM as the only generation engine, yet I also forced "iteration" and "editing" into the same pipeline. adjust still calls the LLM every time — slow and expensive — and the SpecEditor I added later takes a purely local path that never touches the LLM, but the two paths were never truly unified in the data model: adjust relies on the LLM understanding "only change what was asked," while SpecEditor writes spec.json directly without any validation. There was also a problem I didn't see at the time: specToComponent is a pure function with no side effects, which is great, but once a spec.json field type is wrong, the compile step won't error out — the rendering just drifts from expectation. "Compile-time validation" later went into the TODO as a P2 item.

The Core: Why UISpec

Let me state the conclusion first. The most valuable thing to take away from this project is not Electron, IPC, archiving, or single-file export — it's a choice made on day one: don't let the LLM output React code directly; instead, have it emit a UISpec in the middle — a structured JSON tree.

// src/spec/types.ts
interface UISpec {
  title: string;
  root: UINode;
}
interface UINode {
  type: string;
  props: Record<string, unknown>;
  children?: UINode[];
}

Letting the LLM write JSX directly produces extremely unstable output: sometimes a complete component, sometimes half a snippet, sometimes with Markdown mixed in. specToComponent is a pure function that always emits the same JSX for the same input — the LLM's jitter is walled off outside the intermediate layer.

But the spec's real dividend is locatability: it's a hierarchical tree where every node can be indexed by path, which is what gives the #1.2 path references, the SpecEditor Inspector, history archiving, and export somewhere to land. If the LLM output JSX strings directly, none of these capabilities would have anywhere to live.

So this principle can be summarized as: in an AI generation pipeline, the quality of the intermediate representation sets the system's ceiling. Let me unpack it through SpecPulse's evolution.

The Mid-Course Pivot

It finally settled here: an Electron window with a prompt area on the left and a live preview on the right, where components can be clicked to get a #1.2 path reference, the adjustment box supports incremental LLM diffs, edit mode takes a purely local path that writes spec.json directly then recompiles, history is archived by timestamp under generated/<stamp>-<title>/, and the export feature produces a single-file HTML with the spec inlined as window.__UIAGENT_SPEC__.

This shape wasn't planned; it was stumbled into.

First pivot: from "generation" to "iteration"

The first version had only one command:

npm run build -- "a landing page for a cloud storage startup with navbar, hero heading, feature cards and a CTA button"

In src/cli/build.ts, createAgent() is called, the prompt goes into the LLM, the LLM spits out JSON, specToComponent() translates it into JSX, writes it to preview/src/App.tsx, Vite hot-reloads, and a page appears in the browser. Works, but not great to use.

The problem is in the "first time." The LLM's layout is always too monotonous — everything stacks vertically, Grid and Row aren't used by the rules, feature cards that should be side by side get stuffed into a containerless Row, and the colors are absurd. Users aren't satisfied just because you generated a first version; they'll say "make the title blue," "add a Stat card under the Hero," "put the three feature cards side by side."

So I needed incremental adjustment. I wrote src/cli/adjust.ts, whose logic is completely different from build: instead of generating from scratch, it reads the existing record's spec.json and uses expandRefs to expand references like #1.2 in the prompt into concrete node descriptions (e.g., #1.2 [Heading "Product Spec"]), then lets the LLM output the diffed full spec.

When I first implemented it I hit a snag: the LLM would often "helpfully" change parts the user never asked to touch. Add a card under the Hero, and the Navbar's style gets changed too. I hard-coded "only change what the user asked for, keep the rest as-is" into adjust_system_prompt, and added a check in collectTypes — if more than half the component types are lost after adjustment, warn.

What's the cost? Every adjustment still calls the LLM — slow and expensive. The benefit: users can describe incremental changes in natural language without regenerating the whole page.

But this path has a hidden risk: the LLM doesn't always obey, the warning is just a warning — no rollback, no confirmation. Later I wrote "compile-time validation + auto-fix" and "LLM violation downgrade" into the TODO, but at the time they were only ideas.

Second pivot: from "adjustment" to "editing"

Incremental adjustment solved the "describe changes in natural language" problem, but had two drawbacks: every call hits the API — expensive and slow; and the LLM's output is unpredictable, so you don't even know when it changes the wrong place.

I wanted a purely local path. Click a component in the preview, change fields directly in the Inspector, and on Save, bypass the LLM and just run specToComponent once to recompile.

This capability eventually landed in preview/src/PreviewRoot.tsx — it receives the spec from the Electron main process via postMessage, swaps the compiled App for a SpecEditor, writes back to spec.json on save, then calls src/cli/regenerate.ts.

But these two paths were never truly unified at the data-model level: adjust's output still relies on the LLM understanding "only change what the user asked," while SpecEditor's save writes spec.json directly with no validation. The two paths run in parallel, but neither reconciles the other.

The cost is higher architectural complexity — you now have two different editing entry points, one via the LLM and one local; the benefit is a faster iteration path for users, where simple changes don't have to wait for the LLM.

Third pivot: from "overwrite" to "archive"

In the earliest version, build.ts finished and directly overwrote App.tsx; the previous version was just gone. After the user adjusted three times, the very first result vanished completely.

I added the archive() function, called in both build.ts and adjust.ts. Before each execution, it packages the current state into generated/<stamp>-<title>/, storing three files: prompt.txt (the original prompt), spec.json (the intermediate representation), and App.tsx (the compiled artifact). The directory name uses a timestamp plus a sanitized title.

I chose timestamps over a database or ID-generation logic to keep things simple — a string concatenation solves the problem. But using timestamps as the unique identifier means that if two generations complete within the same second, the directories collide; later I saw two consecutive generations produce two directories, and the user couldn't tell which was which.

The cost is that the generated/ directory grows with use, and because prompt.txt contains the original prompt (which may have internal project names or sensitive info), I gitignored this directory. The benefit: version history is always preserved, and spec.json alone can regenerate any version.

Fourth pivot: from "project artifact" to "distributable package"

Once the project was built, I wanted to send results to others. But the prompt might contain sensitive info — internal project names, business context — that I couldn't let the recipient see.

src/cli/export.ts works like this: it reads a record's spec.json, uses Vite to build a spec-driven dynamic rendering runtime, then inlines the spec into the HTML's window.__UIAGENT_SPEC__, inlines the CSS and JS too, and produces a single-file index.html. prompt.txt is left out of the package.

To deploy, you just swap the spec.json and the page changes — no rebuild needed.

This decision looked right at the time — the prompt stays hidden, and inlining the spec guarantees it renders even on a double-click via file://. But it had a side effect: if you want the exported page to be editable online, you have to distribute spec.json alongside it, and the absence of prompt.txt permanently loses the information of "how this page was originally described."

Also, if the inlined third-party dependencies at export time (such as react-three-fiber for 3D scenes) get version updates, you have to manually rebuild. The TODO says "Export enhancement: inline third-party dependencies, output a single build-free shareable HTML," but that's a future task.

Looking back now

This project evolved from the simple "turn text into a page" experiment into its current shape, and every step came from hitting a problem the first loop couldn't solve. I didn't draw an architecture diagram upfront; I started with one command, one function, one preview window, and patched in each piece one at a time.

Every pivot cost architectural complexity in exchange for easing a user pain point. The two parallel paths (LLM adjustment vs. local editing), the history archiving mechanism, the export chain — all were added later, not designed from the start.

The current TODO still has P0-level editor-experience issues (Undo/Redo, drag-and-drop reordering, keyboard shortcuts, component-tree view), P1-level data and versioning (auto-snapshot before editing, save-failure protection, record diff view), and P2-level LLM output robustness (compile-time validation + auto-fix, LLM violation downgrade). All these stem from the same thing: I didn't truly unify the data models of "iteration" and "editing" from the beginning.

But that's engineering practice — get it running first, then patch.

Paths Not Taken

I've laid out the final settled shape for you; now let me tell you about the detours I took.

Today's SpecPulse is the convergence of four paths: an Electron window with a prompt on the left and a live preview on the right; components can be clicked to get path references like #1.2; the adjustment box takes the LLM diff path, where expandRefs in src/cli/adjust.ts expands references into concrete node descriptions before the LLM outputs the full spec; edit mode takes a purely local path, where preview/src/PreviewRoot.tsx receives the spec via postMessage, swaps in a SpecEditor, writes back to spec.json on save, then calls src/cli/regenerate.ts to recompile; history is archived by timestamp under generated/<stamp>-<title>/, storing three files — prompt.txt, spec.json, App.tsx; and the export feature produces a single-file HTML with the spec inlined as window.__UIAGENT_SPEC__, deployable just by swapping the spec.json.

But this setup wasn't born this way. Before it took shape, there were several directions I seriously considered, even half-built and then tore down. They were abandoned for different reasons — some too complex, some too little payoff, some at the wrong time.

Why I didn't let the LLM output React code directly

This was the first path abandoned. My initial idea was: throw the prompt at the LLM, the LLM spits out JSX directly, write it to preview/src/App.tsx, done. No intermediate layer needed — why bother with a spec?

I tried it. JSX output directly from the LLM is extremely unstable — sometimes a complete React component, sometimes half a snippet, sometimes with Markdown formatting mixed in. And specToComponent in src/generator/reactGenerator.ts exists precisely to solve this — it turns the LLM's "unreliable output" into "deterministic compilation."

The value of spec as an intermediate representation is that it's typed, structured, and understandable and editable by an editor. src/spec/types.ts defines the UISpec type, and specToComponent is a pure function that always outputs the same JSX for the same spec input. This enables compile-time validation, makes edit mode possible, and lets adjust do diffs.

If the LLM output code directly, the #1.2 path references in expandRefs would be meaningless — code is a string and can't be located by node. The Inspector in SpecEditor wouldn't work either, because it needs the spec's node structure to build the field list.

The cost is an extra intermediate representation layer, raising architectural complexity. The benefit is that the whole system gains predictability — the LLM's output is constrained within the spec format, giving editing, adjustment, and export a foundation to build on.

Why I didn't build the design system on shadcn/ui

The design system in preview/src/ui.tsx is fully self-built. I considered bringing in shadcn/ui, but the four themes (light / dark / midnight / aurora) need token-driven switching where every component responds to Page.theme, and shadcn/ui's CSS variable system is hard to map directly onto the spec's theme prop — it would need an extra adaptation layer. Self-built is lighter and maps exactly onto the UISpec type.

Why I didn't use a database for history

Each history record under generated/<stamp>-<title>/ stores three files (prompt.txt / spec.json / App.tsx). I considered SQLite, but the generator is a pure function — spec.json alone can rebuild any version, and the filesystem only needs ls to list records; the table design, migrations, and query logic of SQLite aren't worth it for a personal tool. generated/ is gitignored because prompt.txt may contain internal project names — privacy over features.

Why I didn't put editing in the pure browser

Edit mode is currently tied to Electron IPC. I considered abstracting a storage interface and using localStorage / IndexedDB instead of file writes, but Electron IPC is far simpler than browser-side storage — no cross-origin issues, no capacity limits, no file:// read/write restrictions. For a personal tool, Electron is enough; browser-side usability is P4 in the TODO, and the timing isn't right yet.

An Engineering Principle Worth Reusing

I'll pause and think about what's most worth taking away from this project.

Not Electron, not IPC, not the history mechanism, not single-file export. These are all branches that grew while solving problems.

What truly determined whether this system could grow into its current shape was a choice made on day one of the project: don't let the LLM output React code directly; instead, have it emit a UISpec in the middle — a structured JSON tree.

// src/spec/types.ts
interface UISpec {
  title: string;
  root: UINode;
}
interface UINode {
  type: string;
  props: Record<string, unknown>;
  children?: UINode[];
}

At the time this decision looked redundant — why not just let the LLM write JSX? Because directly output code is extremely unstable: sometimes a complete component, sometimes half a snippet, sometimes with Markdown mixed in. And specToComponent is a pure function that always outputs the same JSX for the same input. That means the compilation result is deterministic, and the LLM's jitter is walled off outside the intermediate layer.

But what really let me later build adjust, SpecEditor, archiving, and export was a hidden dividend the spec intermediate representation brought: it is locatable.

The reason expandRefs works — expanding the #1.2 in a hand-written prompt into #1.2 [Heading "Product Spec"] — is precisely that spec is a hierarchical tree where every node can be indexed by path. If the LLM output JSX strings directly, this path reference would have nowhere to live — a string has no nodes, only character sequences.

Likewise, the Inspector in SpecEditor can list editable fields because it reads the spec's node structure, not by parsing a piece of JSX source.

So this principle can be summarized as: in an AI generation pipeline, finding a good-enough intermediate representation — one that gives iteration, editing, tracing, and export somewhere to land — matters far more than designing a perfect front-end architecture up front.

SpecPulse's spec is exactly such an intermediate representation. It's not perfect — the adjust path and the SpecEditor path aren't truly unified at the data-model level, and the TODO lists "compile-time validation + auto-fix" and "auto-snapshot before editing," which are all patches for holes. But precisely because there is a spec, those holes have somewhere to be patched.

Later I also tried approaches without spec — like letting the LLM output JSX directly, or having adjust diff the source-code string directly. The former failed in the very first experiment; the latter made me realize that string diff and node diff are completely different things, and the former is far more likely to change the wrong place.

So the principle I took away is not "get it running first, then patch" — that's the default posture of many personal projects, not my unique experience. What I took away is:

In an AI-driven system, the quality of the intermediate representation sets the system's ceiling. Choose it well, and every later pivot has a fulcrum.

SpecPulse's spec is that fulcrum. It constrains the LLM's unpredictability within a controllable format, giving the later editor, adjuster, archiver, and exporter a shared data foundation. Without this fulcrum, the project would have fallen apart from the start — because the LLM's output is non-deterministic, and non-deterministic things can't be edited, traced, or safely distributed to others.

And this principle isn't limited to UI. The UISpec in SpecPulse could just as well be a Workflow Spec, a Query Spec, an Agent Plan, or a Data Pipeline Spec — the structure is identical: the LLM produces the "intent and structure," and a deterministic runtime does the execution. This is also what I keep exploring in the Agent / Harness direction: give the LLM a good-enough intermediate representation, and separate unpredictable generation from predictable execution completely.

So what this article really wants to say is not "I built an AI page generator," but: in AI application engineering, an intermediate representation plus a deterministic execution layer is what takes a system from "demo-able" to "iterable, traceable, and distributable." UISpec is just the shape it happened to take on the UI.

This is the most important principle I took from this project: in an AI generation pipeline, the quality of the intermediate representation sets the system's ceiling.

Source Navigation

The source is open source on the main branch: https://github.com/erishen/specpulse

Project Address

SpecPulse is an open-source, hands-on project. The source and full usage docs are on GitHub:

  • Repository: https://github.com/erishen/specpulse (default branch main)
  • Local run: git clone https://github.com/erishen/specpulse.git && cd specpulse && npm install && npm run dev

It is positioned as a learning / demo workbench for "natural-language-driven UI generation." Its core exploration is the design trade-offs of the intermediate representation (the spec) in an AI generation pipeline — not a production-grade framework.

FAQ

Q1: Why not let the LLM output React code directly, instead of adding a spec layer?

Directly output JSX is extremely unstable — sometimes a complete component, sometimes half a snippet, sometimes with Markdown mixed in. specToComponent turns the LLM's "unreliable output" into "deterministic compilation": the spec is a typed, structured intermediate representation, and specToComponent is a pure function that always emits the same JSX for the same input, walling the LLM's jitter outside the intermediate layer. More importantly, the spec is a "locatable" tree — which is what gives the #1.2 path references, the SpecEditor Inspector's field list, history archiving, and export somewhere to land.

Q2: What's the difference between incremental adjustment (adjust) and in-page editing (SpecEditor)?

adjust takes the LLM diff path: it reads the existing spec.json, uses expandRefs to expand #1.2 references in the prompt into node descriptions, then lets the LLM output the diffed full spec — slow, expensive, and unpredictable. SpecEditor takes a purely local path: click a component in the preview, edit fields in the Inspector, and on Save it just runs specToComponent once to recompile, bypassing the LLM — fast and deterministic. The two are not yet truly unified at the data-model level: adjust relies on the LLM understanding "only change what was asked," while SpecEditor's save writes spec.json directly with no validation.

Q3: Why use the filesystem (generated/ directory) for history instead of a database?

The generator is a pure function, so spec.json alone can rebuild any version; the filesystem only needs ls to list all records. Introducing SQLite would mean installing a dependency, designing tables, handling migrations, and writing query logic — complexity that isn't worth it for a personal tool. generated/ is gitignored because prompt.txt holds the original prompt (which may carry internal project names) — privacy over features.

Q4: Why doesn't the exported single-file HTML include the prompt?

The prompt may contain internal project names or sensitive business info that you don't want the recipient to see. src/cli/export.ts inlines the spec into window.__UIAGENT_SPEC__, along with the CSS and JS, producing a single-file index.html; to redeploy you just swap the spec.json — no rebuild needed. The side effect is that the information of "how this page was originally described" is lost.

Q5: Why hand-build the design system instead of using shadcn/ui?

The four themes — light / dark / midnight / aurora — need token-driven switching, and every component must respond to Page.theme. shadcn/ui's CSS variable system is hard to map directly onto the spec's theme prop and would need an extra adaptation layer. A self-built design system is lighter and maps exactly onto the UISpec type; src/agent/agent.ts's SYSTEM_PROMPT explicitly specifies the use case for each theme.

Q6: What happens when the spec is wrong? Is there compile-time validation?

specToComponent is a pure function with no side effects, so once spec.json has a wrong field type or a className uses an invalid special value, the compilation stage won't error out — the rendered result just deviates from expectations. Compile-time validation is still a P2 TODO item ("compile-time validation + auto-fix," "LLM violation downgrade") and is not yet implemented.

AI Engineering Practices & Open Source Projects

Home Resume Shop Web Chat Nsbp About Privacy

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号