Zest

How it works

On this page

For contributors. Zest has no dependencies: Node built-ins on the server, plain JavaScript in the page.

plugin/
  .claude-plugin/plugin.json   the plugin manifest
  bin/zest                     launcher: checks the Node version, loads lib/cli.mjs
  skills/edit, skills/run      /artifact-editor:edit and /artifact-editor:run
  lib/
    html.mjs       the source mapper (below)
    server.mjs     the editor for one folder (createEditor: file watching, the API the editor calls), and a server of its own for it
    service.mjs    the Zest service on port 4470: editors for every folder in one process, page sessions, the button
    freeze.mjs     freeze frames: a page's markup at one instant, drawn again by headless Chrome with no script running
    store.mjs      .zest/ records: notes, runs, edits, versions, provenance, grooves (what past rounds did)
    images.mjs     library, search, proxy, download, saving assets
    providers.mjs  image generation adapters
    keys.mjs       key lookup and the config file
    check.mjs      checks, and head-tag edits
    bundle.mjs     localize and single-file export
    shoot.mjs      screenshots over the DevTools protocol, on a pipe (Chrome opens no port)
    prompt.mjs     the prompts the editor copies, and `notes pending`
    insert.mjs     placeholders for new images and sections: what an insert carries, and its words
    runner.mjs     what Submit starts: Claude Code, scoped and capped; a team's listener; another model; the slim profile
    round.mjs      a round split into tasks: snapshots, lanes, parallel worktrees, merge back, checks, repair, the trace
    route.mjs      what kind of work each note is, how notes group into tasks, which lane takes each, the cap preview
    packet.mjs     the one prompt a task gets: notes, the files behind the page, context, brief, groove, what to do
    snap.mjs       the checkout as a commit (never touching the index), worktrees from it, three-way merge back
    trace.mjs      a round's phases from its events, saved as trace.json and report.html
    labels.mjs     Right / Not right, and the reviewer that labels answers after a round
    evals.mjs      cases from labelled rounds, replays on other models, scoring against the mark, route tuning
    ledger.mjs     one file per machine of requests, readings, results and outcomes, outside every checkout
    owner.mjs      who already works in a page's checkout, from an extension's owner hook, and telling it (deliver)
    mailbox.mjs    notes handed to that agent: one append-only file per agent, outside every checkout
    wait.mjs       Submit held until that agent goes quiet: the queue under ZEST_HOME and the waiter that starts the round
    doctor.mjs     the health table
    update.mjs     the daily check for a newer release
    egress.mjs     every outbound call, and the organization's policy file
    cli.mjs        the zest command
  ui/
    editor.js      in the page: selection, text edits, notes, pins, live updates, the page's state, repaints
    button.js      the Zest Edit button: Edit here, and a still copy in its menu
    panels.js      the drawer: Changes, Page, Check, Versions, Brief, Setup
    picker.js      the image picker, framing, transparent keying, shrinking
    loop.js        Submit on the bar, the setup walk-through, the pulse and the marks
    insert.js      Insert mode: the line, the dialogs, the placeholders
    editor.css     the editor's styles, inside a shadow root
    about.html     About Zest, opened from the Z in the toolbar
    logo/          Zest's mark
  examples/welcome the demo page
library/           the starter image library, served by jsDelivr
test/              unit and server tests (node --test); e2e.mjs, e2e-deck.mjs, e2e-levers.mjs, e2e-live.mjs, e2e-inpage.mjs,
                   e2e-loop.mjs and e2e-route.mjs drive Chrome
site/              the public site: build.mjs turns docs/ and home.html into GitHub Pages
scripts/release.mjs  the release workflow's checks

The source mapper

lib/html.mjs is a scanner, not a parser. It walks the HTML once and records, with source offsets, every run of text that becomes a text node, every start tag and its attribute spans, and every raw block (<script>, <style>). It decodes entities while keeping a map from each decoded character back to its byte offset.

A text edit from the browser arrives as this text node held old, now holds next, plus where it sat among identical text nodes in the document and the text on either side. The mapper:

  1. finds every source run whose decoded text equals old exactly (scripts, styles, comments and <template> content are never candidates)
  2. picks one: the only match; or the match at the same position, when the browser saw the same number; or the match whose neighbouring runs equal the neighbours the browser reported
  3. diffs old and next to their common prefix and suffix, and replaces only the changed middle, through the offset map, so entities and whitespace outside the change survive

Anything else returns not-found or ambiguous with a count, and the editor turns the change into a note. Attribute edits match by id, then by the attribute's current value, then by position among same-named tags, with the same count check.

The editor

ui/editor.js loads after the page, attaches one shadow root to <html>, and loads panels.js, picker.js, loop.js (Submit, its setup walk-through and the pulse) and insert.js (Insert mode and the placeholders it draws from notes). All Zest styles live inside the shadow root. Fonts are declared on the page under private names (Zest Serif, Zest Sans) because @font-face does not work inside a shadow root.

In edit mode, clicks are captured before the page sees them. A click on text makes the nearest text leaf (an element whose descendants are all inline) editable with contenteditable="plaintext-only". Before the edit starts, the editor records each text slot between element boundaries and where it sits in the document. On Enter, it compares slots, so retyping a whole heading (which replaces the text node) still maps cleanly.

The server pushes events over Server-Sent Events: saved (by this server), changed (by anything else), notes (a run started or finished). The page reloads itself when the file changes and the person is not mid-edit; during a run it waits for the finish.

One server

createEditor({ root, where }) in server.mjs is the editor for one folder as a request handler that does not listen. createServer puts one on a port of its own (zest serve, and zest open when the service is not running). The Zest service (service.mjs) mounts one per folder in its own process, made the first time a page in it opens and put away after two idle hours, and routes each request to it:

In a running app's page the app owns the DOM. editor.js keeps its listeners on the window and the document, finds the selection, an open note and the pins again after every repaint (a MutationObserver), and turns words typed into a part the app replaced into a note. Every note and edit carries the page's state; a note's freeze frame is drawn by freeze.mjs, which loads the page's own address in headless Chrome with scripts off and answers that one request with the markup the editor took.

Adding an image provider

  1. Write async function myProvider({ dir, prompt, aspect, n, reference, quality }) in providers.mjs returning [{ buffer, mime, model }]. Throw ProviderError with a fix for every failure a person can act on.
  2. Add it to PROVIDERS with a label and a one-line note.
  3. Add its key names to KEY_NAMES in keys.mjs.
  4. Add a mocked-fetch test in test/server.test.mjs.

Tests

npm test        # unit and server tests, about a second
npm run e2e     # drives Chrome through a full round on the demo page, a live page, and a page edited in place

ZEST_SHOT_SCALE=2 node test/e2e.mjs <folder> saves a sharp screenshot of every state, which is how the images in the README and on the site are made.

Releasing

plugin.json has no version, on purpose: with none, the commit is the version, so every push to main reaches everyone who has auto-update on. A pinned version that nobody bumps stops updates silently.

A release is a dated, named point people can read about and check against: a tag artifact-editor--v<version> (the form claude plugin tag uses), the same version in plugin/package.json and package.json, and a section in CHANGELOG.md. Pushing the tag runs .github/workflows/release.yml, which checks all three agree, runs the tests and publishes the GitHub release. Every install asks GitHub once a day for the newest release and says so when it is behind (lib/update.mjs). Steps: CONTRIBUTING.md.

The editor's files are cache-busted by a hash of their contents, so a browser never runs an old editor against a new server. Add a line to CHANGELOG.md for anything a user would notice.

Suggest a change to this page