npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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

pnpm add -D @guerrerosoconm/visual-editor

ESM 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_ENV guard 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.json

4. 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 manifest below. 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 get edits.json or 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 source field 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 aliased cva import all read correctly, and tv() 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=1 response 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 selectorAmbiguous when they can't be), but a list that reorders between sessions can still misattribute an edit. With source stamping the server merges on source instead, 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 it sourceAmbiguous and merging falls back to the selector. Prefer data-testid on 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. :hover has no computed style the browser will hand to JS, so states.hover ships the value you want and not the one you had — every other before in 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 typecheck

Both 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