Skip to content

Undoable, exportable edits with layout- and token-aware editing - #1

Open
Hunter-Kendall wants to merge 9 commits into
mainfrom
christmas-bullet
Open

Undoable, exportable edits with layout- and token-aware editing#1
Hunter-Kendall wants to merge 9 commits into
mainfrom
christmas-bullet

Conversation

@Hunter-Kendall

Copy link
Copy Markdown
Collaborator

Makes VisBug's edits durable: they go through a store instead of inline styles, so they can be undone, exported, and handed back to a codebase as a prompt. Along the way, dragging and the nudge keys learn about the page they're editing — its layout and its design tokens.

What's in it

A style store and history journal (app/core/)
Every feature used to write el.style[prop] = value directly, which made undo and clean export impossible. editStyle() now writes into an editor-owned stylesheet keyed by a generated class (selector repeated 3x for specificity, so no !important noise in exports). history.js journals DOM moves, attributes, and text alongside styles — duplicate, delete, group/ungroup, and keyboard nudges all undo via cmd+z / cmd+shift+z. Rapid edits coalesce; a continuous drag collapses into one entry.

Layout-aware drag and drop (app/features/dropzones.js)
Dragging previously only swapped an element with whatever it was dropped on — no insertion between siblings, no crossing containers, and no regard for the parent's display. dropzones.js resolves a real insertion point from the container's layout: a caret along a flex container's main axis (honouring row-reverse and wrapped lines), a cell in a grid, line-aware carets in inline flow, row/cell insertion in tables, and free XY placement with edge snapping for absolutely positioned elements. Empty containers accept drops. For block/inline containers the axis is measured rather than read off display. move.js switched from HTML5 drag-and-drop to pointer events for precise coordinates; a 4px threshold keeps a plain click selecting.

Design-token-aware nudging (app/core/tokens.js)
Pure px arithmetic fights a page built on a token scale. Tokens discovers the page's own CSS custom properties (or Optics' specifically, via a cross-origin-safe probe), groups the ones forming a scale, and lets margin/padding/font/box-shadow step through them — shift is the escape hatch back to px. Measurement overlays read as the token name (e.g. --op-space-medium) when a value lands on a scale step; color picking gets a swatch list drawn from the page's palette.

Export as HTML + CSS (app/core/export.js)
collectCSS() gathers everything that styles the page: inline <style>, same-origin links, @imported sheets, adopted stylesheets, and cross-origin sheets that have to be fetched. media="print" links are wrapped in @media; url()s are rewritten against their own sheet's URL. HTML gets src/href/srcset resolved to absolute and editor markup dropped. Scripts are stripped — otherwise the page's own scripts re-run on open and rebuild the DOM out from under the snapshot.

Copy changes as a prompt (app/core/prompt.js, app/core/changes.js)
A flattened page is the wrong shape for getting design changes back into a codebase. The hard part is naming the element each edit belongs to in a way that survives the trip from a rendered page back to source, so every element ships with each anchor available — test attribute, id, css selector, opening tag, text, ancestor trail, dom path — ordered by how well they map onto code, letting the agent pick what fits. changes.js records only what a thing looked like before VisBug touched it and computes the net change at report time, so forty arrow-key nudges read as one change and undone edits drop out on their own. Downloading is still available via /export.

Extension fix
Fixes a race in extension/visbug.js where color-mode/scheme preferences could be sent before toolbar/inject.js registered its listener on first launch; the send is extracted into extension/contextmenu/send.js.

Testing

New unit tests for changes, export, tokens, and dropzones; existing feature tests updated to read authored values back through readStyle instead of asserting on el.style.

Demo pages: app/optics.html, app/dropzones.html, app/changes.html, app/export.html.

Hunter-Kendall and others added 9 commits August 27, 2026 08:47
Every feature wrote `el.style[prop] = value` directly, which made two
things impossible: recording a before/after for undo, and exporting
usable CSS instead of a page full of inline styles.

Introduce app/core as the single funnel. editStyle() writes the
declaration into an editor-owned stylesheet keyed by a generated class
and hands a change record to the history journal. Selectors repeat the
class three times (0,3,0) so the rule beats most author CSS without
resorting to !important, which would be noise in an export.

history.js journals DOM moves, attributes and text alongside styles, so
duplicate, delete, group/ungroup and keyboard nudges all undo. Rapid
edits coalesce on an idle window, and beginGesture/endGesture collapses
a continuous drag into one entry. Bound to cmd+z / cmd+shift+z.

Overlays and drag ghosts keep their inline styles — isEditorChrome()
keeps ephemeral UI out of the journal and out of exports.

Export falls out cheaply now that edits live in a real stylesheet:
/export, /export css strip the editor's bookkeeping and emit the markup
alongside the rules.

Existing tests asserted against el.style, so they read the authored
value back through the new readStyle helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dragging used to call swapElements(), which exchanged two siblings. It
could not insert between them, could not cross into another container,
refused to run when the element had no siblings, and never once looked
at the parent's display — flex, grid and block all behaved identically.

dropzones.js resolves a real insertion point from the container's
layout: a caret along the main axis of a flex container (honouring
row-reverse and grouping wrapped items into lines), a cell in a grid, a
line-aware caret in inline flow, row/cell insertion in tables, and free
XY placement with edge snapping for absolutely positioned elements.
Empty containers now accept drops. It is pure geometry — move.js
applies the result.

For block and inline containers the axis is measured rather than read
off `display`: a block container full of inline-block children lays out
as a row, and trusting display alone drew the caret on the wrong axis.

Grid distinguishes the two cases. An auto-flow grid reorders in the DOM
and lets the browser re-place, preserving auto-placement; a grid whose
children are explicitly placed pins the dragged element to the cell.

move.js drives this with pointer events instead of HTML5 drag-and-drop,
for precise coordinates and control over the overlay. A 4px threshold
keeps a plain click selecting rather than dragging, and the whole
gesture commits as one undo entry. Keyboard nudging is unchanged.

visbug-insertion draws the caret and cell highlight. It needs
`inset: … auto auto …` to escape the popover UA styles that would
otherwise centre it in the viewport, matching the other overlays, and
joins isOffBounds so it never becomes its own drop target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Export existed only as a search command, and it emitted just the editor's
own rules — the page's stylesheets stayed behind as <link> tags pointing
at the origin, so a saved file looked unstyled offline.

collectCSS() now gathers everything that styles the page: inline <style>
blocks, same-origin links, @import-ed sheets, adopted stylesheets, and
cross-origin sheets whose cssRules throw and have to be fetched instead.
media="print" links are wrapped in @media so their scope survives, and
url()s are rewritten against their own sheet's URL so images and fonts
still resolve from the saved copy. The editor's rules go last so they win.

The HTML gets the same treatment: src/href/srcset resolved to absolute,
editor markup and bookkeeping attributes dropped. Scripts are stripped —
an export is a snapshot of how the page looks now, and left in place the
page's own scripts re-run on open and rebuild the DOM out from under it,
VisBug's bundle included.

The button sits in a new actions row under the colors. Saving two files
makes Chrome ask once to allow multiple downloads; /export single still
folds the CSS into a <style> tag for one self-contained file.

Two fixes found while testing: the editor stylesheet was emitted twice,
because the dedup compared pretty-printed text against cssText and never
matched — it now skips by identity. And the toolbar, already taller than
a 1366x768 viewport before this row, cut the new button off entirely, so
it tightens up on short screens rather than putting items out of reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Downloading a flattened page is the wrong shape for getting design changes
back into a codebase — nobody wants a 5000-line stylesheet, they want to
know which elements changed and what happened to them.

The hard part isn't listing the edits, it's naming the element each one
belongs to in a way that survives the trip from a rendered page back to
source. No single selector does that: an nth-child path is unique but says
nothing about which component owns it, a class is meaningful but may repeat,
a test id is decisive but often absent. So every element ships with each
anchor we can establish — test attribute, id, css selector, opening tag,
text, ancestor trail, dom path — ordered by how well they tend to map onto
code, and the agent picks whichever fits the codebase in front of it. The
css selector falls back to an id-scoped nth-child so it stays unique without
losing the container's name.

changes.js records only what a thing looked like *before* VisBug touched it.
The net change is worked out at report time against what's authored now, so
forty arrow-key nudges read as one change and anything undone drops out on
its own — replaying the journal would have reported both.

Anchors are cleaned through the DOM rather than by regex, so no data-vb-id,
generated class, grab cursor or empty style attribute leaks into text that
is meant to look like source.

Downloading is still available as /export; the prompt is also on /copy
changes. Capturing originals moved to edit.js, which breaks the import cycle
that would otherwise exist between changes.js and style-store.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…in, font, box-shadow, and color

VisBug's nudges were pure px arithmetic, which fights a page built on a
token scale. app/core/tokens.js discovers a page's own CSS custom
properties (or Optics' specifically, via a cross-origin-safe probe),
groups the ones that form a scale, and lets margin/padding/font/box-shadow
step through them instead of by raw pixels — shift is the escape hatch
back to px. Margin/padding measurement overlays now read as the token
name (e.g. --op-space-medium) when a value lands exactly on a scale step.
Color picking gets a matching swatch list drawn from the page's palette.

Also fixes a race in extension/visbug.js where color-mode/scheme
preferences could be sent before toolbar/inject.js had registered its
listener on first launch, and extracts that send into
extension/contextmenu/send.js so it's handled once instead of at each
call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves the copy-prompt button into the same <ol colors> as the
foreground/background/border swatches so it reads as one group, and
swaps its bespoke circle styling for the shared color-swatch look.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant