Skip to content

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:

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

StackPrecise editingHow
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.php

Open 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:

php
// 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:

  1. writes its SWC plugin to node_modules/@portbay/swc-plugin-loc/. This is not a dependency — your package.json and 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 a pnpm install prunes it;
  2. adds the registration below to your next.config.*, at the end of the config object, under a comment saying PortBay put it there.
js
// 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.0 stamps 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.1 handles both, so next dev works as it is on Next 16 (Turbopack by default) and on Next 15 (webpack by default). If your lockfile pins 0.1.0, upgrade it.

Laravel (Blade) ​

bash
composer require --dev portbay/blade-stamper
php artisan view:clear

Laravel'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 the view:clear.
  • Nothing is emitted when APP_ENV=production. To force it on or off, set PORTBAY_LOC in .env, not in your shell: php artisan serve passes only a fixed allow-list of environment variables to the PHP process and drops the rest, so PORTBAY_LOC=0 php artisan serve has 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 for local and development, off for staging, production, and for a site that never set WP_ENVIRONMENT_TYPE at all — that function answers production when it is unset. The default is chosen for how the file travels: an mu-plugin lives inside wp-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 ) in wp-config.php overrides 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 as wp-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) ​

bash
bundle add portbay-erb-stamper

Django ​

bash
pip install portbay-django-stamper

Then 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 ​

EditFallback (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-loc is 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:line message. The class you changed comes from part of an expression PortBay can't read statically — a className prop 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 vs HEAD (the same set the in-app Diff review shows): per-file project-relative path, created flag, kind, and exact before/after line counts. Pass includeContent: true to 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.
bash
portbay-mcp --toolsets editor
# or alongside the board + verify tools
portbay-mcp --toolsets tasks,verify,editor

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

Was this helpful?
Feedback

PortBay is pre-MVP software. Use the docs as an operating guide, not a stability guarantee.