Visual Editor — Precise Editing
PortBay's visual editor lets you click an element on your running .test site and edit its text, classes, and styles, writing the change back into your real source files. By default it resolves a clicked element back to source with a text search — it looks for the rendered text or class= value in your project files and patches it when it finds exactly one match.
That works well for static HTML and unique CSS selectors, but it has hard limits:
- A class built from an expression (
cn(...),:class,[ngClass]) has no literal in source to rewrite. - Vue/Svelte scoped styles drop to an override stylesheet instead of the real rule.
- Dynamic / loop content (
.map(),v-for,{#each}) is ambiguous — N rendered copies look identical to a text search, so the edit is refused. - Repeated text across the page can resolve to the wrong element.
Precise editing removes those limits. A small dev-time build plugin stamps each rendered element with its authored source location:
<button data-pb-loc="src/components/Hero.vue:42:7">Get started</button>PortBay reads that attribute, opens the exact file at that line, verifies the element still matches what you clicked, and patches that one element — so repeated v-for / {#each} items and scoped component styles resolve deterministically.
Dev-only, by design
The data-pb-loc attribute is emitted only in development. It never reaches a production build, so there's no DOM bloat and no leaking of local file paths. The gate is NODE_ENV !== 'production'; set PORTBAY_LOC=1 to force it on, or PORTBAY_LOC=0 to force it off.
Is it on?
For Astro, Vue, Svelte and React projects running on Vite, it is already on. PortBay launches your dev server, so it adds its own source-location plugin to that launch from a config it generates outside your project. There is nothing to install and nothing to paste, and your package.json, node_modules and config files are byte-identical with or without PortBay — remove PortBay and not one byte of your project changes.
Open the edit bar on your site to see the state:
- Precise (green dot) — elements are stamped; edits resolve to the exact source line.
- Precise: off (amber dot) — click the chip and PortBay tells you exactly what is degraded for your stack, and why.
Where it is on, and where it is not
PortBay's stamper scans in two directions: markup-first over the families whose markup is lexically separate from their code (.astro, .vue, .svelte, .html), and script-first over .jsx and .tsx, where the whole file is JavaScript and the markup sits in expression position.
| Stack | Precise editing | How |
|---|---|---|
| Astro | ✅ | PortBay injects its plugin into astro dev |
| Vue (Vite) | ✅ | PortBay injects its plugin into vite |
| Svelte / SvelteKit (Vite) | ✅ | PortBay injects its plugin into vite |
| React / Preact / Solid / Qwik (Vite) | ✅ | PortBay injects its plugin into vite; .jsx and .tsx are scanned script-first, so your own components are stamped — minus the component-usage tier below |
| Next.js (SWC — Turbopack and webpack) | ✅ | Nothing — PortBay wires it when it starts your dev server |
| Babel pipelines (CRA, custom webpack) | ❌ | No injection seam, and no plugin |
| WordPress | ✅ | Nothing — PortBay writes portbay-loc.php into wp-content/mu-plugins/ when the editor turns on for the site |
| Laravel (Blade) | ✅ | portbay/blade-stamper (Composer), which you install yourself — not yet zero-setup |
| Rails (ERB) | ✅ | portbay-erb-stamper (RubyGems), which you install yourself — not yet zero-setup |
| Django | ✅ | portbay-django-stamper (PyPI) plus portbay_loc in INSTALLED_APPS — not yet zero-setup |
| Hugo, Jekyll, Eleventy | ❌ | Server-rendered with no compile hook PortBay can reach |
The three lanes marked not yet zero-setup are the remaining gap, and they are a gap in PortBay rather than a fact about those stacks: each stamper is a file PortBay could write into the project the way it already writes the WordPress must-use plugin, and the dispatch that would do it is one function (live_preview::instrument::ensure). What each still needs is a boot hook verified against a real app of that framework, because getting a service provider, an initializer or an INSTALLED_APPS edit wrong stops the app from starting — which is a worse failure than a grey badge.
.mdx is the one extension refused by name: its top level is Markdown, where a < is neither markup nor an expression until the MDX compiler has decided which — and neither scanner is the thing that decides.
What JSX does not get
Component call sites in a script-first file carry nothing. Telling <Card/> from a TypeScript generic parameter list (<T,>(x: T) => x) needs the TypeScript grammar rather than a lexer, and a wrong insertion would be a syntax error in your own module — so PortBay stamps host elements only rather than guess. The concrete loss: an element produced by a component you did not author (a library's own markup) carries no source coordinate. The .astro, .vue and .svelte lanes do have that tier, because those frameworks forward an unrecognised prop onto a component's rendered root; React does not do that at all.
Where precise editing is off, text, class and attribute edits still work by searching your source. Structural edits are refused rather than guessed, style edits go to PortBay's override sheet, and CMS-driven elements cannot be detected as CMS-driven.
WordPress
WordPress has no dev server to hand a config to, so its stamping lives inside the site as a must-use plugin:
wp-content/mu-plugins/portbay-loc.phpOpen the edit bar on a WordPress project and PortBay offers to write it for you. It only ever creates the file — a copy already there is left exactly as it is — and you can copy it yourself from packages/wp-stamper/portbay-loc.php instead.
It is off until the site says it is a development site. The plugin follows WordPress's own wp_get_environment_type(): on for local and development, off for staging and production — and production is what WordPress answers when WP_ENVIRONMENT_TYPE is unset, which is every stock install. So:
// wp-config.php
define( 'WP_ENVIRONMENT_TYPE', 'local' );or set PORTBAY_LOC=1, which overrides in both directions. The default is deliberate: an mu-plugin lives inside wp-content, so backups, staging clones and migration plugins carry it around, and the copy has to be inert when it lands somewhere it was not meant for.
With the plugin on, clicking a block selects it and PortBay writes back to the block markup in wp_posts.post_content (or the wp_template row for a block theme) rather than searching your theme's PHP. Without it, every element reads "page not stamped": text and class edits still work by searching your theme templates, and structural edits are refused.
Next.js (SWC)
You do not install anything. When PortBay starts a Next project's dev server it does two things first, so the server is stamping from its very first response:
- writes its SWC plugin to
node_modules/@portbay/swc-plugin-loc/. This is not a dependency — yourpackage.jsonand your lockfile are not touched, and deleting the directory only turns precise editing back off. PortBay re-adds it on the next start, including after apnpm installprunes it; - adds the registration below to your
next.config.*, at the end of the config object, under a comment saying PortBay put it there.
// next.config.js — dev only; `next dev --turbopack` reads the same key
module.exports = {
// Added by PortBay — dev-time source-location stamping (data-pb-loc).
// The plugin self-gates on NODE_ENV and emits nothing in a production build.
experimental: {
swcPlugins: (() => {
// PortBay's plugin lives in node_modules, which you don't commit. This asks
// for it before registering it, so a clone or a CI checkout that doesn't have
// it builds normally instead of failing on a plugin it can't resolve.
try {
if (typeof require === 'function') require.resolve('@portbay/swc-plugin-loc');
} catch {
return [];
}
return [['@portbay/swc-plugin-loc', { root: process.cwd(), enabled: process.env.NODE_ENV !== 'production' }]];
})(),
},
}next.config.* is a file you version; node_modules is not. Registering the plugin unconditionally would work on the machine PortBay ran on and fail everywhere else — a teammate's clone, a fresh CI checkout — with next build refusing to resolve @portbay/swc-plugin-loc. The IIFE asks the question itself, on whichever machine loads the config: present, it registers the plugin; absent, experimental.swcPlugins evaluates to [], same as if you'd never set it. The resolved path is thrown away either way — only the throw is read — because handing Next a resolved path instead of the bare specifier is what makes Turbopack refuse the build.
The config write is the only change PortBay makes to a file you version, and it is one insertion at one offset — the rest of your config keeps its formatting, comments and ordering exactly. It goes in last so it cannot be overridden by a spread above it, which is why a config opening with ...(isStaticExport && { output: "export" }) is wired rather than refused.
PortBay writes nothing and says so if your config is a shape it cannot edit deterministically — a withMDX(cfg) wrapper around the export, an experimental that is not a plain object, or an experimental.swcPlugins that already lists plugins (appending to that would silently change your transform order). In those cases the precise-editing panel shows the exact text to add, and the block above is it.
If you would rather do it yourself, pnpm add -D @portbay/swc-plugin-loc@^0.1.1 plus that config is the same thing by hand; PortBay sees the registration and leaves your project alone.
Version floor: 0.1.1.
0.1.0stamps nothing under Turbopack, silently, whatever you put in the config — Turbopack hands the plugin a project-relative filename where webpack hands it an absolute one, and 0.1.0 gives up on that without saying so.0.1.1handles both, sonext devworks as it is on Next 16 (Turbopack by default) and on Next 15 (webpack by default). If your lockfile pins0.1.0, upgrade it.
Laravel (Blade)
composer require --dev portbay/blade-stamper
php artisan view:clearLaravel's package auto-discovery registers the service provider, so there is no config file to edit. This is one lane for the whole Laravel family: the stamper compiles Blade the same way whatever else composer.json lists, so a project using Livewire or Inertia gets precise editing on exactly the terms a plain Laravel project does. Three things worth knowing:
- Stamping is decided at Blade compile time and frozen into
storage/framework/views, which Blade only rebuilds when a template's mtime changes — hence theview:clear. - Nothing is emitted when
APP_ENV=production. To force it on or off, setPORTBAY_LOCin.env, not in your shell:php artisan servepasses only a fixed allow-list of environment variables to the PHP process and drops the rest, soPORTBAY_LOC=0 php artisan servehas no effect. - On Livewire, a
wire:re-render replaces markup in the page. The replacement is stamped too — Livewire renders through the same templates — but a live selection can still reset when one lands, so edit and Save rather than live-tweak. Inertia page components are Vue, Svelte or React files served through Vite, which is stamped separately and needs nothing installed; the stamper covers the Blade shell around them.
WordPress
Open the site in the browser bar. If the file is missing, the bar says so and offers Install the PortBay WordPress helper; pressing it writes one file — wp-content/mu-plugins/portbay-loc.php — and changes nothing else about your site. Must-use plugins load automatically and cannot be deactivated from the admin, so there is nothing to activate.
Two things worth knowing:
- Installing it is not the same as turning it on. The switch is WordPress's own
wp_get_environment_type(): on forlocalanddevelopment, off forstaging,production, and for a site that never setWP_ENVIRONMENT_TYPEat all — that function answersproductionwhen it is unset. The default is chosen for how the file travels: an mu-plugin lives insidewp-content, so backups, staging clones and migration plugins carry it around without ever looking at it, and the copy has to be inert when it lands.define( 'PORTBAY_LOC', true )inwp-config.phpoverrides in either direction, and is checked first. - What it stamps is a record, not a file. Four of the five places a WordPress element can come from are rows rather than source — block markup in
wp_posts.post_content, a Full Site Editing template, an ACF value, an Elementor node — so those carry a destination such aswp-block:<post_id>:<index_path>in place of a line number, and PortBay resolves the element back to the record that produced it. Elements your theme writes as PHP markup keep resolving by searching that source.
Rails (ERB)
bundle add portbay-erb-stamperDjango
pip install portbay-django-stamperThen add portbay_loc to INSTALLED_APPS.
What gets stamped
Only real host elements (the lowercase intrinsic tags that produce a DOM node — <div>, <li>, <button>) are stamped. Components (<Hero>), TypeScript generics (useState<string>()), and framework wrappers (<template>, <slot>, <script>, <style>) are never touched. Each element carries the location of its own opening <, with the line as the firm anchor (the column is a tie-breaker, since compilers disagree on column bases).
Precise vs. fallback at a glance
| Edit | Fallback (text search) | Precise (data-pb-loc) |
|---|---|---|
Static HTML text / class= | ✅ | ✅ |
React className="…" | ✅ when the value is unique in your source | ✅ exact element |
| Markup from a component you didn't author | ❌ | ❌ — no usage tier in JSX (see above) |
.map() / v-for / {#each} item | ❌ refused (ambiguous) | ✅ patches the one template element |
| Repeated text across the page | ⚠️ may mis-resolve | ✅ exact element |
| Vue / Svelte scoped styles | ⚠️ drops to override CSS | ✅ resolves in the component file |
A computed class (cn("p-2", isWide && "p-8")) | ❌ | ✅ rewrites the static literal inside the call |
A class from an argument PortBay can't read (cn("p-2", className)) | ❌ | refused with a precise file:line message |
Where stamping isn't available, PortBay falls back to the text-search resolver with zero behavior change — nothing you have working today regresses. It says so rather than degrading quietly.
Server-rendered templates
There's no JS build step to hook for Blade, ERB, Django templates, Twig or Liquid, so the build plugins don't cover them. The resolver itself is framework-agnostic — it consumes data-pb-loc no matter who emitted it — so the moment any instrumentation stamps the attribute in your rendered HTML, those edits resolve precisely too. Blade, ERB and Django templates each have that instrumentation now, as a package you install (see the three sections above); Twig and Liquid do not, and until they do they use the text-search fallback.
Both server-side stampers keep their coordinates across the composition their template language does: ERB across partials, the layout, content_for blocks and collection rendering; Django across {% include %}, {% extends %} and {% block %} — each template is stamped in its own source. What neither can stamp is an element that was never written as markup: tag.div / content_tag / link_to in Ruby, or anything built in the browser by JavaScript. Those still resolve by text search.
Troubleshooting
- Chip says "off" after installing. Restart the dev server, then reload the page so the freshly stamped HTML is served. The chip reflects whether
data-pb-locis present in the current DOM. - No chip at all. PortBay couldn't detect a supported bundler. Confirm
vite(or a Babel config) is present in the project root. - An edit refuses with a
file:linemessage. The class you changed comes from part of an expression PortBay can't read statically — aclassNameprop passed in,styles.card,getVariant(size)— so rewriting the static string beside it wouldn't change what renders. Edit it in code. A literal inside the same call (cn("p-2", …)) is rewritten normally.
Inspecting the editor over MCP
A BYO agent (Claude Code, Cursor) can read the editor's persisted state — and roll it back — through four MCP tools in the opt-in editor toolset:
portbay_editor_working_diff— the project's uncommitted source changes vsHEAD(the same set the in-app Diff review shows): per-file project-relative path,createdflag, kind, and exact before/after line counts. PassincludeContent: trueto also get each file's before/after text (capped per file). Read-only.portbay_editor_history— the visual-edit apply ledger, newest first: the human-readable edit summaries, file count, and the surface-minted transaction id — the agent-attributed edit trail. Read-only.portbay_editor_restore_preview— exactly what restoring one history entry would change, without writing anything: every file the restore would touch, whether it would be deleted, and whether the entry is still restorable at all. Read-only.portbay_editor_restore— destructive. Rolls the tree back to the state one history entry captured; the same restore the human runs from the in-app History panel.
portbay-mcp --toolsets editor
# or alongside the board + verify tools
portbay-mcp --toolsets tasks,verify,editorAll four only exist on a Pro build compiled with both the tasks and visual-editor features, like the verify and browser toolsets. They work off disk (the git working tree and .portbay/visual-history); they do not touch the live page. Driving the live canvas from an agent — reading the selection, querying elements, applying guarded edits — is a separate, approval-gated surface that needs an app-process bridge and is not part of this layer.
Restoring from an agent
portbay_editor_restore runs the same engine as the in-app History restore, so it inherits every guard: the reviewed-bytes pin, a re-read of each target immediately before the write, staged-then-renamed writes, a crash journal, and a roll-forward snapshot that makes the restore itself restorable (its id comes back as rollbackEntryId).
A restore is entry-scoped, not file-scoped: it writes every file the entry captured and cannot restore one file while leaving the entry's others alone. So the tool takes paths as an exact consent list rather than a filter — call portbay_editor_restore_preview, then pass back every project-relative path it listed. A mismatch refuses and writes nothing, returning notConfirmed with unnamed (files the restore would change that you did not name) and notInRestore (paths you named that it would not touch). notConfirmed is a decision to make, never a call to retry unchanged.
The three read-only tools survive --read-only; portbay_editor_restore does not.
