Skip to content

Move the original/optimised switch onto the canvas, and add a diff view - #12

Merged
ericges merged 6 commits into
mainfrom
feat/canvas-view-modes-and-diff
Aug 18, 2026
Merged

ericges merged 6 commits into
mainfrom
feat/canvas-view-modes-and-diff

Conversation

@ericges

@ericges ericges commented Aug 17, 2026

Copy link
Copy Markdown
Owner

"Show original" was never a setting. It changes no byte of SVGO's output — which is why it lived in unfingerprinted, why _saveSettings() stripped it before persisting, and why collectNotes() needed a special case to silence every collision notice while it was on. It was a lens sitting in a panel of things that produce the result, and a boolean can't express the third state a diff needs.

Two commits: the refactor first, green and hand-verifiable on its own, then the feature.

1. The view mode

src/js/page/ui/view-mode.js is a self-created radio group at the top left of .output, outside the .output-switcher subtree so SvgOutput's pan gestures can't swallow it. It emits change with { value }; MainController owns what a mode means, keeping the latest result as _resultItem beside _inputItem and composing the two controls in _renderOutput().

Two behaviour changes, both deliberate

  • Optimisation now runs while the original is showing. The if (settings.original) short-circuit in _compressSvg() is gone, so every settings change optimises whatever is on screen. Showing the original used to buy silence on a slow file; that's the price of the mode being a lens rather than a switch on the pipeline.
  • The size comparison no longer disappears in that mode. The readout reports input → output whenever a result exists. It used to collapse to a single number under "Show original" — the numbers describe the settings, not what is being looked at.

Collision notices consequently hold in every mode, so the gate in collectNotes() and the compareToFile ternary in _updateForFile() both go.

Removing the field was fingerprint-neutral — it was excluded from the fingerprint all along, so every result cached in the wild stays valid. The default fingerprint literal in test/settings-model.test.js is deliberately unchanged, and that is what proves it. No migration either: SettingsModel.set() ignores names it doesn't have, so a legacy payload carrying original passes through untouched.

The panel's View group had only gzip left in it, so that moves to the top of Output and the heading goes.

2. Diff

A third segment composing with the toolbar rather than replacing it — the toggler is untouched, no coupling and no programmatic writes into it. Output grows from two output types to four:

view mode type file
Preview Optimised / Original image result / input
Preview Diff renderDiff both
Markup Optimised / Original code result / input
Markup Diff markupDiff both

Visual diff: both files rasterised into one box and compared with pixelmatch, on its own PanZoom. The box comes from the input file, since pixelmatch compares buffers of equal length — an optimisation that changes the intrinsic size then lands elsewhere inside that box, which is the honest picture of that change. The rasterise half of png-button.js is factored into ui/rasterize.js rather than written twice. The anti-aliasing threshold is tuned against measurements on the demos, recorded in the constant's comment: at pixelmatch's stock 0.1 the tiger leaves 322 stray red pixels on a default load and the flag 37; at 0.2 they leave 25 and 8, while precision 0 on the tiger still lights up 943.

Markup diff: with Prettify off — the default — the optimised markup is one line, so a naive line diff says "everything changed" and is worthless. Both sides are reflowed to one element per line, unconditionally, so the diff reads identically however that toggle moves (verified: same 554 rows and 247 runs either way). The reflow is cosmetic and reaches nothing that is exported, copied, measured, cached or fingerprinted, and the view says so in a line of its own. jsdiff runs bounded by maxEditLength because this is the main thread; past the bound it degrades to a trimmed prefix and suffix around one removed and one added block rather than refusing.

Row type is carried by a +/-/space gutter character as well as a tint — colour alone reaches nobody who can't tell the two apart. No syntax highlighting: Prism highlights a document, not a diff of two.

Dependencies

package licence why
pixelmatch 7.2.0 ISC ~4KB, dependency-free in the bundle (its pngjs dep is CLI-only). Takes two equal-sized buffers and writes the highlight image.
diff 9.0.0 BSD-3-Clause ~20KB before tree-shaking, no dependencies. diffLines, rather than a hand-rolled Myers implementation.

Both bundle through Rollup like svgo and css-tree, with no gulpfile change.

Two fixes that fell out

  • Output now records its files synchronously in set() and clears them in reset(). Its switch queue means a later update() can land mid-switch, and a type switched into between a new file and its first result would otherwise re-render the file that was just reset away.
  • .code-output gains the top padding that clears the new control, which landed over its first line in commit 1.

Verified by hand

In npm run dev, service worker cleared first:

  • Every combination of the two controls maps to the right output type, and twelve switches ran the worker zero times (instrumented Worker.prototype.postMessage) — a mode change re-renders from the files in hand.
  • Pan/zoom survives Optimised ↔ Original: the .svg-container transform is byte-identical across the switch while the iframe src changes.
  • Preview ↔ Markup keeps the mode; Diff works in both.
  • Visual diff on an untouched car shows 1 changed pixel in 87,048; precision 0 on the tiger lights up the damage across the outlines.
  • Markup diff of the SVGO logo is identical with Prettify on and off.
  • "No changes" and "No visible changes" both reachable (an SVG SVGO re-emits byte for byte).
  • A 2,500-element pathological pair hits the maxEditLength bail and degrades to exactly context(1), remove(2500), add(2500), context(1) without hanging.
  • Loading a new file while in Diff keeps the mode and refreshes the comparison.
  • Reset leaves the mode alone, and so does undo.
  • After a reload the mode is back to Optimised while the settings and the panel layout are restored; the persisted payload carries no original key.
  • A collision notice appears in every mode (kitchen sink, 5 notices while in Diff).
  • Download/Copy/PNG follow what's on screen — Diff hands over the optimised result (same memoised blob URL as Optimised), Original hands over the input.

One thing worth recording: the first switch between output types takes ~1s because transitionToClass() loses its transitionend race and waits out the timeout. That is pre-existing — I measured 1034ms for the same switch on origin/main — so it is left alone here.

CLAUDE.md and README.md are updated with the PR, not after it.

It was never a setting. It changes no byte of SVGO's output — which is
why it sat in `unfingerprinted`, why `_saveSettings()` stripped it before
persisting, and why `collectNotes()` needed a special case to silence
every notice while it was on. A lens in a panel of things that produce
the result, and a boolean that cannot express the third state a diff
view needs.

It becomes `ui/view-mode.js`: a self-created radio group at the top left
of `.output`, outside the `.output-switcher` subtree so `SvgOutput`'s pan
gestures can't swallow it. `MainController` keeps the latest result as
`_resultItem` beside `_inputItem` and composes the two controls in
`_renderOutput()`, which memoises the pair it last rendered.

Two behaviour changes fall out of it, both deliberate:

- Optimisation now runs while the original is showing. The
  `if (settings.original)` short-circuit in `_compressSvg()` is gone, so
  showing the original no longer buys silence on a slow file. That is the
  price of the mode being a lens rather than a switch on the pipeline.
- The size readout always reports input → output. It used to collapse to
  a single number in original mode; the numbers describe the settings,
  not what is being looked at.

Collision notices consequently hold in every mode, so the gate in
`collectNotes()` and the `compareToFile` ternary in `_updateForFile()`
both go. Removing the field from `globalFields` was fingerprint-neutral —
the default fingerprint literal in `test/settings-model.test.js` is
unchanged on purpose, which is what proves every result cached in the
wild stayed valid. No migration: `SettingsModel.set()` ignores names it
doesn't have, so a legacy payload carrying `original` passes through.

The panel's View group had only `gzip` left in it, so that moves to the
top of Output and the heading goes.
A third segment on the canvas control, composing with the toolbar's
Preview/Markup toggler rather than replacing it: `Output` grows from two
output types to four, and `MainController._renderOutput()` composes the
pair of controls into a type and the file (or files) that go into it.
The toggler is untouched — no coupling, no programmatic writes into it.

In Preview it is a visual diff: both files rasterised into one box and
compared with `pixelmatch`, on its own `PanZoom` so the checkerboard,
centring and gestures match the preview it stands in for. The box comes
from the *input* file, since pixelmatch compares buffers of equal length
— when an optimisation changes the intrinsic size, the result landing
elsewhere inside that box is the honest picture of that change. The
rasterise half of `png-button.js` is factored out into `ui/rasterize.js`
rather than written twice; Firefox's refusal to draw an SVG with no
intrinsic size was already solved there.

In Markup it is a line diff, and its design point is that with Prettify
off — the default — the optimised markup is one line, so a naive line
diff says "everything changed" and is worthless. Both sides are reflowed
to one element per line, unconditionally, so the diff reads the same
however that toggle moves; the view says so in a line of its own, since
the reflow is cosmetic and reaches nothing that is exported, copied,
measured, cached or fingerprinted. jsdiff runs bounded by `maxEditLength`
because this is the main thread, and past the bound it degrades to a
trimmed prefix and suffix around one removed and one added block rather
than refusing.

Two libraries, both bundled like svgo and css-tree with no gulpfile
change: pixelmatch (ISC) and diff (jsdiff, BSD-3).

Two fixes fall out of the work. `Output` now records its files
synchronously in `set()` and clears them in `reset()`: its switch queue
means a later `update()` can land mid-switch, and a type switched into
between a new file and its first result would otherwise re-render the
file that was just reset away. And `.code-output` gains the top padding
that clears the view mode switch — the control landed over its first
line in the previous commit.
@ericges
ericges force-pushed the feat/canvas-view-modes-and-diff branch from e0efa00 to 55bb40b Compare August 18, 2026 16:31
…oots`

Account for new bundled dependencies introduced by the diff view feature. Align `NOTICE.md` and tests to include missing `pixelmatch` and `diff` entries, ensuring accurate license declarations and compliance.
… `setting-reset` button design, and improve ripple effect transparency.
@ericges
ericges merged commit b5509e5 into main Aug 18, 2026
5 checks passed
@ericges
ericges deleted the feat/canvas-view-modes-and-diff branch August 18, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant