HTML-source-of-truth visual editor primitives + React UI. Built to power AgentSite — the "edit after the agent ships it" layer for AI-generated websites.
AgentSite's PM → Designer → Developer → Reviewer pipeline produces real HTML/CSS/JS files. Once they're generated, users want to nudge them — change a headline, swap an image, tweak a color — without spinning the whole agent loop back up. htmlstudio is that nudge layer. The HTML the agents emit stays the source of truth; every visual edit is a tiny, typed patch on that string — the same shape an LLM tool-call would emit, so the agents and the human edit through the same channel.
Light inspiration from GrapesJS (the inspector-panel mental model) and open-design's edit-mode bridge (source-mapped data-* ids + postMessage round-trip), but htmlstudio is intentionally small and scoped — no scene graph, no plugin runtime, no block library to maintain. Just the three primitives AgentSite needs.
Three primitives, ~400 LOC of source:
| Primitive | What it does |
|---|---|
tagHtml(html) |
Walks the tree, stamps stable data-ve-id="p-0-1-2" ids on every meaningful element. Idempotent. |
buildBridgeScript(opts) / injectBridge(html, opts) |
A <script> you inject into a preview iframe. Handles hover outlines, click-to-select, contenteditable double-click-to-edit, and postMessages typed events to the host. |
applyPatch(source, patch) |
Six patch kinds: set-text, set-link, set-image, set-style, set-attributes, set-outer-html, set-full-source. Mutates by id, returns new source. |
AI emits HTML
│
▼
tagHtml ─────► HTML + data-ve-id="p-0-1-2" on each element
│
▼
injectBridge ─► same HTML + bridge <script> before </body>
│
▼
preview iframe renders it; user hovers / clicks / dblclicks
│
▼ (postMessage, channel: 've')
host receives BridgeEvent { type: 'select' | 'dblclick-text' | ... }
│
▼
host builds a Patch; applyPatch(source, patch)
│
▼
new source string ─► save to DB / re-render iframe
npm install htmlstudioimport { tagHtml, injectBridge, applyPatch } from 'htmlstudio';
const raw = '<section><h1>Hello</h1><p>world</p></section>';
const tagged = tagHtml(raw);
// <section data-ve-id="p-0"><h1 data-ve-id="p-0-0">Hello</h1><p data-ve-id="p-0-1">world</p></section>
const previewHtml = injectBridge(`<!doctype html><body>${tagged}</body>`, {
targetOrigin: 'https://your-host.app',
});
// → serve this in the iframe
// when the user edits a text node:
const r = applyPatch(tagged, { kind: 'set-text', id: 'p-0-0', value: 'Hi there' });
console.log(r.source); // updated HTML, ready to persistwindow.addEventListener('message', (e) => {
if (e.data?.channel !== 've') return;
switch (e.data.type) {
case 'ready': console.log(`${e.data.payload.count} elements tagged`); break;
case 'hover': /* show breadcrumb */ break;
case 'select': /* render inspector panel */ break;
case 'dblclick-text': /* user finished inline editing; build a set-text patch */ break;
}
});
// host → iframe commands
iframe.contentWindow.postMessage(
{ channel: 've', type: 'highlight', payload: { id: 'p-0-1' } },
'https://preview.app',
);type Patch =
| { kind: 'set-text'; id: string; value: string }
| { kind: 'set-link'; id: string; href: string; text: string }
| { kind: 'set-image'; id: string; src: string; alt: string }
| { kind: 'set-style'; id: string; styles: Record<string, string> } // '' removes
| { kind: 'set-attributes'; id: string; attributes: Record<string, string | null> } // null removes
| { kind: 'set-outer-html'; id: string; html: string } // id slot preserved
| { kind: 'set-full-source'; source: string };The package ships drop-in React components under htmlstudio/react — the same surface AgentSite uses internally. Peer deps: react, react-dom, @phosphor-icons/react. Styling is built-in: import htmlstudio/styles.css once at the app root. The components use hs-* classes plus CSS variables, so Tailwind is not required — override the variables in your own CSS to theme.
import 'htmlstudio/styles.css';
import { useVisualEdit, PreviewFrame, RightRail } from 'htmlstudio/react';
import { BUILTIN_BLOCKS, renderBlock } from 'htmlstudio';
function Editor({ html, onSave }) {
const visual = useVisualEdit({
loadSource: () => html,
saveSource: onSave,
enabled: true,
});
return (
<div className="flex h-screen">
<main className="flex-1">
<PreviewFrame
editSrcDoc={visual.srcDoc}
iframeRef={visual.previewFrameRef}
/>
</main>
<RightRail
selection={visual.selection}
selections={visual.selections}
onApply={visual.applyPatch}
onApplyMany={visual.applyPatches}
onClearSelection={visual.clearSelection}
saveState={visual.saveState}
blocks={BUILTIN_BLOCKS}
onInsert={(def) =>
visual.selection &&
visual.applyPatch({
kind: 'set-outer-html',
id: visual.selection.id,
html: renderBlock(def, {}),
})
}
/>
</div>
);
}Exports: useVisualEdit, PreviewFrame, DeviceFrame, DeviceSwitcher, ZoomControls, BlocksPanel, BlockConfigForm, EditInspector, RightRail.
A Vite + React + Tailwind editor that loads a sample page and persists edits to localStorage:
npm install
npm run build # builds htmlstudio
npm run demo:install
npm run demo # → http://127.0.0.1:5180Or browse the hosted version: https://jhd3197.github.io/htmlstudio/.
npm install
npm run build # tsc → dist/
npm test # vitest, 25 tests
npm run test:watch
npm run dev # tsc --watch- Source = HTML. No proprietary scene graph, no schema migrations, no lossy import/export. The string the agent writes is the string the editor mutates.
- Agent-native. Every patch maps 1:1 to an LLM tool-call, so AgentSite's agents and a human user edit through the exact same surface.
- Tiny and boring. One runtime dep (
node-html-parser). Bridge script is ~3 KB unminified. Pure TS core. - Not a website builder. It's a primitives layer. AgentSite owns the surfaces (chat, pipeline, generation); htmlstudio owns "click a thing → change a thing → patch the source."
- Undo/redo stack helper.
- Style-token patches (
set-token) for design-system-aware editing. - AI tool-call adapter (
patchToToolCall/toolCallToPatch). - Optional shipped device-frame SVGs (currently
DEFAULT_FRAMESis empty — callers provide their ownframesprop).
MIT © Juan Denis