@guerrerosoconm/visual-editor
v0.4.0
Published
Dev-only in-browser visual editor: click any DOM element, tweak its styles in a Figma-style panel, and export the changes as JSON for an AI coding agent to apply to the source.
Maintainers
Readme
A dev-only visual editor that mounts into your app. Click any element on the page, adjust its styles and text in a Figma-style panel with live feedback, and export the changes as structured JSON that an AI coding agent (Claude Code, Cursor, …) applies to your actual source.
It doesn't touch your components. It captures intent — with enough anchors (class names, test ids, React component ancestry, before/after diffs) for an agent to find the JSX that produced the element and translate the change into whatever styling system you use.
| | |
|---|---|
| Zero dependencies | react / react-dom as peers, nothing else. No Tailwind, no CSS framework. Styles are injected at runtime under prefixed .fve-* class names. |
| Design-system aware | It detects the component behind an element and lets you flip variant="default" → variant="destructive" live, exporting a prop change rather than fourteen CSS diffs. |
| Framework-agnostic persistence | The server adapter returns Web-standard Request → Response handlers for Next.js App Router, Remix, Hono, or plain Node. |
| Production-safe | Mount it behind a NODE_ENV check; the endpoint 404s outside development. |
- Install
- Quickstart
- How it works
- Source stamping
- The panel
- Design-system editing
- API
- Other frameworks
- FAQ
- Limitations
- Development
Install
pnpm add -D @guerrerosoconm/visual-editorESM only, types included. Needs Node 20+ for the server adapter; the editor itself is a React component and runs wherever React does.
Quickstart
1. Mount the editor
// app/layout.tsx
import { VisualEditor } from '@guerrerosoconm/visual-editor';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
{process.env.NODE_ENV === 'development' && <VisualEditor />}
</body>
</html>
);
}[!WARNING] The
NODE_ENVguard is what keeps it out of your production bundle. Don't skip it.
[!TIP] Not a React app? Call
mountVisualEditor()instead — same editor, one line, no JSX.
2. Expose the edits endpoint
// app/api/dev/visual-edits/route.ts
import { createVisualEditsHandlers } from '@guerrerosoconm/visual-editor/server';
export const { GET, POST, DELETE } = createVisualEditsHandlers();The default path is /api/dev/visual-edits. To mount it elsewhere, tell both sides:
<VisualEditor endpoint="/__dev/edits" />3. Ignore the output file
edits.json4. Edit
Open your app in dev. Then:
| Action | How |
|---|---|
| Toggle the editor | The floating button, or ⌘⇧E / Ctrl⇧E |
| Inspect | Hover — the element highlights with its tag, component and size |
| Select | Click it — the panel opens |
| Walk the tree | ↑ / ↓, or the breadcrumb trail in the panel |
| Find a property | Type in the panel's search box |
| Nudge a value | ↑/↓ in any number field (Shift ×10, Alt ×0.1) |
| Undo / redo | ⌘Z / ⌘⇧Z, or the arrows in the panel header |
| Edit a :hover style | The state tabs at the top of the panel |
| Deselect | Esc |
| Close | Esc again, or ⌘⇧E — it asks first if anything is unsaved |
| Editor preferences | Right-click the floating button |
| Move the button | Drag it — it snaps to the nearest corner and remembers |
Edit as many elements as you like, then hit Save N edits. Every touched element is written
to edits.json and logged to the console as [AI-EDIT-REQUEST].
5. Hand it to your agent
Read edits.json. For each edit, locate the element in the source using source (when
present), className, testId and componentStack, translate the style values into our
Tailwind classes, apply the changes — including any states.hover / states.focus /
states.active as the hover:/focus:/active: variants — then clear the file.How it works
flowchart LR
A[hover] --> B[click] --> C["tweak<br/>(live inline styles)"] --> D[Save]
D --> E["console<br/>[AI-EDIT-REQUEST]"]
D --> F["POST /api/dev/visual-edits"] --> G[(edits.json)]
G --> H[your AI agent] --> I[real source changes]Changes preview as inline styles, which makes them perfectly reversible — closing the editor
reverts everything unsaved, so you can never orphan a style. Values are read from
getComputedStyle, so the baseline is always in pixels; mapping 12px back to p-3 is the
agent's job, and the before side of every diff is what makes that possible.
See docs/ARCHITECTURE.md for the full picture.
Source stamping
Opt-in, dev-only, one line of config — and the agent stops searching for your JSX and starts being handed it.
// vite.config.ts
import { visualEditorSource } from '@guerrerosoconm/visual-editor/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
// Before react(): the JSX has to still be there to be stamped.
plugins: [visualEditorSource(), react()],
});Every host element gets the file, line and column of its own tag:
<button data-fve-src="src/components/Button.tsx:42:7" class="btn">The editor reads the nearest one — the element's own or an ancestor's — and every edit carries it:
"source": "src/components/Button.tsx:42:7"| | |
|---|---|
| Dev only, structurally | The plugin declares apply: 'serve'. A production build never runs it, whatever the config says. |
| Still zero dependencies | It parses with the typescript your project already has, loaded lazily. Nothing new is installed. |
| Nothing regresses without it | No plugin means source: null, and the payload anchors on componentStack / className / selector exactly as before. |
Options: include (default /\.[jt]sx$/) and exclude (default /node_modules/), both RegExp.
Needs a TypeScript with a JS parser — that is, TypeScript 5.x. TypeScript 7 dropped in-memory parsing from its JS API, so on 7 you have to hand the plugin a 5.x compiler yourself:
visualEditorSource({ typescript }). With neither, it warns once and does nothing; edits keep working with the anchors they always had.
The panel
| Section | What it edits |
|---|---|
| Component | Design-system variants — appears when the element's component is detected |
| Layout | Width, height, translate X/Y, rotation, display, and flex controls when applicable |
| Spacing | Padding and margin, linked or per-side |
| Appearance | Opacity, corner radius (linked or per-corner), blend mode |
| Fill | Background color, with alpha |
| Stroke | Border width, style, color |
| Typography | Font, size, weight, line height, letter spacing, align, transform, color |
| Effects | Drop shadows — one editable row per layer, Figma-style, with inset, reorder and add/remove — plus blur and backdrop blur |
| Text | The element's text content — when it has no element children |
| Note | A free-text message to the agent about this element — why, or anything the diff can't say |
| Custom CSS | Any property/value pair, applied live |
Sections collapse to a summary (Fill ● #0EA5E9). The changes strip above the footer lists
every pending diff with a per-property revert.
State tabs sit at the top of the panel: Default / :hover / :focus / :active. Edit with
one selected and the element previews as it would look in that state — the pseudo-class can't be
forced from JS, but seeing the values is what you're after — and the edit exports under
states.hover rather than styles. It's the one thing the payload couldn't say at all before: a
hover style was simply unsayable.
Undo / redo is ⌘Z / ⌘⇧Z, page-wide and in the order you did things, so undoing something
on another element selects it and shows you the change. A gesture is one step — holding an arrow
key or scrubbing a value collapses to a single entry. Saving doesn't clear the history, but undo
never reaches into edits.json: it makes the element dirty again, which is the truth.
Every element carrying a note gets a numbered pin on the page, Figma-style — hover to read it, click to jump back to that element. A note is the one edit that changes nothing visually, so without the pin the only way to find it again is to remember which element it was on.
Design-system editing
This is the part worth caring about.
If your components use class-variance-authority (the shadcn/ui convention),
the server scans your component files, extracts each cva() definition, and sends the panel a
manifest of components and their variant axes. When you select an element the editor identifies
which component rendered it — by React component name, or by matching base classes, which is how
it also works for server-rendered DOM.
You then get a Component section with real dropdowns for variant, size, and any other
axis. Switching one swaps the actual classes on the element, so the preview is a genuine variant,
not a CSS approximation.
The payload difference is the point:
// ❌ Pixel editing — lossy intent, the agent has to guess
"styles": { "background-color": { "before": "rgb(15, 23, 42)", "after": "rgb(239, 68, 68)" },
"color": { "before": "rgb(248, 250, 252)", "after": "rgb(255, 255, 255)" } }
// ✅ Variant editing — exact intent, the agent just applies it
"component": { "name": "Button", "file": "components/ui/button.tsx",
"props": { "variant": { "before": "default", "after": "destructive" } } }[!NOTE] Not using cva? Supply the manifest yourself — see
manifestbelow. Supply nothing and the Component section simply doesn't appear; everything else works.
API
<VisualEditor />
The React component. Mount it once, inside <body>, behind a dev check.
| Prop | Default | Purpose |
|---|---|---|
| endpoint | /api/dev/visual-edits | Where the handlers are mounted. Used for the manifest, saves and Clear. |
mountVisualEditor(props?)
The same editor for hosts that are not React — Vue, Angular, Svelte, Astro, plain HTML. It boots
into its own React root appended to <body>, so the host never touches React. Takes the same props
as the component and returns { unmount() }.
import { mountVisualEditor } from '@guerrerosoconm/visual-editor';
if (import.meta.env.DEV) mountVisualEditor();Non-React projects should import from /standalone instead — same function, React bundled in,
nothing extra to install:
import { mountVisualEditor } from '@guerrerosoconm/visual-editor/standalone';(The main entry keeps react/react-dom as optional peers, so React hosts reuse their own copy.)
INSTRUCTIONS
The field-by-field contract for the receiving agent, as a string. Copy for agent prepends it
automatically and GET ?instructions=1 serves it; it's exported for your own tooling.
createVisualEditsHandlers(options?)
Returns { GET, POST, DELETE } as Web-standard fetch handlers.
interface VisualEditsHandlerOptions {
/** Where to persist edits. Default: `<cwd>/edits.json` */
filePath?: string;
/** Extra guard on top of NODE_ENV. Default: `process.env.NODE_ENV === 'development'` */
enabled?: boolean;
/** Built-in CVA scanner config. Pass `false` to disable scanning. */
components?: { roots?: string[]; cwd?: string } | false;
/** Full override for non-CVA projects: supply the design-system manifest yourself. */
manifest?: ComponentSpec[] | (() => Promise<ComponentSpec[]>);
}| Route | Does |
|---|---|
| GET | Returns { ok, edits } |
| GET ?manifest=1 | Returns { ok, components, warnings } — the design-system manifest, plus any { file, reason } the scanner had to drop (cached 5s) |
| GET ?instructions=1 | Returns { ok, instructions } — the contract for the agent |
| POST | Validates and appends a VisualEdit; re-editing the same element merges into its entry, keeping the oldest before. Same element means the same source when both entries carry an unambiguous one, the same selector otherwise — always within one url |
| DELETE ?selector=&url= | Removes the one entry matching that identity; &source= narrows it the same way the merge does. Returns { ok, removed, count }. Half a pair is a 400 |
| DELETE | Clears the file |
All routes 404 when not enabled.
export const { GET, POST, DELETE } = createVisualEditsHandlers({
components: { roots: ['app/ui', 'packages/design-system/src'] },
});export const { GET, POST, DELETE } = createVisualEditsHandlers({
manifest: [{
name: 'Button',
file: 'src/components/Button.tsx',
base: 'btn',
axes: {
variant: { values: { primary: 'btn--primary', ghost: 'btn--ghost' }, defaultValue: 'primary' },
size: { values: { sm: 'btn--sm', lg: 'btn--lg' }, defaultValue: 'sm' },
},
}],
});Types
VisualEdit, StyleChange, ComponentSpec, VariantAxis are all exported from the root.
interface VisualEdit {
timestamp: string;
url: string;
viewport: { width: number; height: number };
selector: string; // #id, [data-testid=…], or an nth-child path anchored at body
selectorAmbiguous?: true; // set when the selector couldn't be verified unique — trust the anchors below
tagName: string;
className: string; // usually the best grep anchor into your source
testId: string | null; // nearest data-testid, self or ancestor
source?: string | null; // 'src/components/Button.tsx:42:7' with the build plugin — see Source stamping
sourceAmbiguous?: true; // set when the page renders that stamp more than once (a component used twice)
componentStack: string[]; // React ancestry, e.g. ['Button', 'ProjectCard', 'DashboardPage']
styles: Record<string, { before: string; after: string }>;
states?: { // per pseudo-class, the value you want — no `before`, see Limitations
hover?: Record<string, string>;
focus?: Record<string, string>;
active?: Record<string, string>;
};
text: { before: string; after: string } | null;
note: string | null; // your message to the agent — it is told to treat this as the intent
component: { name: string; file: string; props: Record<string, StyleChange> } | null;
}Other frameworks
The editor boots from one call in any framework — it injects its own styles, paints into its
own root, and reads nothing from the host. Component-by-name detection reads dev-build internals
and currently speaks React (fibers), Vue 3 (__vueParentComponent) and Angular
(the window.ng debug API): on all three, the payload carries the component ancestry and live
props/inputs. On other hosts (Svelte, Solid, plain HTML) that layer stays off and the payload
anchors on data-testid, class, and a unique selector instead — still everything an agent needs
to find the source.
// Vue — main.ts
if (import.meta.env.DEV) import('@guerrerosoconm/visual-editor/standalone').then((m) => m.mountVisualEditor());// Astro — a client-side <script>, or any onMount / ngOnInit / onMounted hook
import { mountVisualEditor } from '@guerrerosoconm/visual-editor/standalone';
if (import.meta.env.DEV) mountVisualEditor();The /standalone entry ships with React bundled in, so there is nothing else to install; React
hosts use the main entry, which reuses their own React.
See playground/vanilla.html for a full working page with no framework at all.
The server adapter speaks Web standards, so anything that hands you a Request works:
import { createVisualEditsHandlers } from '@guerrerosoconm/visual-editor/server';
const h = createVisualEditsHandlers();
app.get('/api/dev/visual-edits', (c) => h.GET(c.req.raw));
app.post('/api/dev/visual-edits', (c) => h.POST(c.req.raw));
app.delete('/api/dev/visual-edits', () => h.DELETE());// app/routes/api.dev.visual-edits.ts
const h = createVisualEditsHandlers();
export const loader = ({ request }) => h.GET(request);
export const action = ({ request }) => (request.method === 'DELETE' ? h.DELETE() : h.POST(request));// src/pages/api/dev/visual-edits.ts
import type { APIRoute } from 'astro';
export const prerender = false; // POST/DELETE need a server route, even in dev
const h = createVisualEditsHandlers();
export const GET: APIRoute = ({ request }) => h.GET(request);
export const POST: APIRoute = ({ request }) => h.POST(request);
export const DELETE: APIRoute = () => h.DELETE();Astro hands routes an APIContext, not a bare Request, so unwrap { request } rather than
re-exporting the handlers directly. astro dev serves the route with no adapter installed — the
adapter warning only matters for a production build, which this endpoint never reaches.
[!NOTE] No backend at all (Vite SPA, CRA)? The editor still works — every save is logged to the console as
[AI-EDIT-REQUEST], and Copy JSON puts the whole batch on your clipboard to paste straight into your agent. You just don't getedits.jsonor the design-system manifest.
FAQ
Only if you mount it unconditionally, which you shouldn't. The endpoint independently 404s outside development.
No. It writes edits.json and nothing else. Your agent makes the code changes, and you review
that diff like any other.
Because the browser only knows computed values. That's why every diff carries before — the
agent maps before: 12px to the p-3 in your JSX and picks the right target class.
Check the browser console first: every definition the scanner had to drop is logged there with the
file and the reason, so if it read your components and gave up on one, it says which and why.
Nothing in the console means it never saw them — they're outside the scanned roots (pass
components: { roots: [...] }), they don't use cva() or tv(), or the endpoint isn't reachable.
GET /api/dev/visual-edits?manifest=1 returns both the components and the warnings.
Closing the editor still discards what you didn't save — but it asks first now, and so does the browser if you reload with anything pending. A hot reload no longer costs you the session: when React remounts an element the editor re-resolves it and moves the session onto the new node, so only elements that genuinely left the page are reported as lost. A full page reload does still clear everything, deliberately — after your agent edits the source, the page should show the source rather than your stale preview painted over it.
Limitations
Known and honest:
- A full page reload wipes the session. Pending edits live in memory, and rehydrating them is a deliberate non-goal — after the agent edits the source, the page should show the source. The browser now warns before unloading while anything is pending, so the loss is a choice rather than a surprise. (Fast Refresh preserves the session either way — the state is in a ref.)
- Unsaved edits are discarded on close — by design, but never silently: closing with pending
edits asks first, in the panel footer, with
Save N/Discard/Cancel. Nothing is ever written without you asking for it. - The React fiber walk uses private internals and only works in dev builds. Component names
degrade to tag+class when unavailable. With source stamping enabled this
stops mattering for locating code — the
sourcefield comes from an attribute a build step wrote, not from React's internals — and the fiber walk is left as the fallback for hosts that don't run the plugin, plus the source of the names shown in the panel. - The cva scanner needs a TypeScript with a parser to be exact. With one installed it walks
the real AST: quoted axis keys, template literals,
cn()composition and an aliasedcvaimport all read correctly, andtv()works too. Without one it falls back to the original regex parser, which yields partial axes on those. Either way every definition it has to drop comes back as a{ file, reason }warning in the?manifest=1response and is logged once to the browser console, so a missing Component section is traceable to the file that caused it. TypeScript 7 removed the in-memory parser from its JS API — see source stamping for the same caveat and the way round it. - Structural selectors are position-dependent. They're verified to be unique when written
(and flagged
selectorAmbiguouswhen they can't be), but a list that reorders between sessions can still misattribute an edit. With source stamping the server merges onsourceinstead, which a reorder can't move. That only helps where the stamp names one element: a component used twice stamps both instances from its single definition, so the payload marks itsourceAmbiguousand merging falls back to the selector. Preferdata-testidon things you edit often. - No multi-select. N elements already produce N clean edits one at a time; the mixed-value panel state is the whole cost, and it buys nothing the payload doesn't already say.
- State edits carry no
before.:hoverhas no computed style the browser will hand to JS, sostates.hoverships the value you want and not the one you had — every otherbeforein the payload is measured, and this one would have to be guessed.
See docs/ARCHITECTURE.md for why.
Development
npm install # workspace root
npm run dev # playground on http://localhost:5180, source stamping on
FVE_SOURCE=0 npm run dev # the same playground with the plugin off
npm run typecheckBoth paths matter, so both are one shell variable apart: with the plugin, every edit in
playground/edits.json carries source; with FVE_SOURCE=0, source is null and the payload
falls back to the fiber walk — which is what most hosts will actually run.
playground/ is a Vite + React + Tailwind v4 app with cva components — deliberately the exact
stack this library targets, so the paths that matter (oklch colours, variant detection, repeated
lists, multi-layer shadows) are exercised for real. Its vite.config.ts mounts the server
handlers as dev-server middleware, which doubles as proof that the Web-standard handlers work in
a host with no API-route convention.
MIT © Marcos Guerreros Ocon
Changelog · Architecture · Issues
