Skip to content

Roadmap phases 1-6: destinations, plain English, one design system, the layout rebuild, the proof, and shipping - #1

Merged
SyedSaribSultan merged 13 commits into
mainfrom
phase-4-layout
Aug 10, 2026
Merged

Roadmap phases 1-6: destinations, plain English, one design system, the layout rebuild, the proof, and shipping#1
SyedSaribSultan merged 13 commits into
mainfrom
phase-4-layout

Conversation

@SyedSaribSultan

Copy link
Copy Markdown
Owner

Eleven commits, stacked as four branches, all present here. main is untouched until this merges.

The six phases

Phase What changed
1 — Destinations Presets became five destinations named after where the image is going. The default moved from figma to web, so somebody compressing for a website stopped silently losing WebP for a reason about Figma's plugin API.
2 — Plain English One word per concept everywhere a person can see it. "version" not "candidate", "your target" not "floor". Every rejected version now says why it lost, in one sentence.
3 — One design system Both interfaces render from one token layer. The desktop app's private palette is deleted. Motion is enforced, and no animation may transition a layout property.
4 — The layout Both interfaces rebuilt into four states: work happening, one image, the list, and one panel from one edge. The comparison went from roughly a quarter of the screen to 93% of its area.
5 — Show the proof Resize savings separated from compression savings. The transparency check made visible. A comparison page generated from the benchmark data.
6 — Shipping PyInstaller spec, a release workflow with imgcompress --check as a release-blocking gate, a download page, and a Figma spike with a probe.

Version 2.7.0. The default destination changed, thumbnail changed values and --preset/--target merged, so it is a minor bump with documented behaviour changes. Older names (figma, archive) still resolve.

Bugs found and fixed along the way

  • A fork bomb. compress_tree uses a process pool and nothing called multiprocessing.freeze_support(). Frozen, each worker re-executes the application's own binary, which starts a whole new imgcompress and opens a pool of its own. It hid because the single-job path is single-process, so every one-image smoke test passed.
  • The desktop app was one HTTP 403 from rendering in Times New Roman. A <link> cannot carry the query string the page was opened with, so the token check refused the app's own stylesheets and Chrome dropped them for a JSON MIME type. Every static check stayed green throughout; the new runtime gate caught it on its first run.
  • The desktop app badged a rejected version as the winner — it marked the smallest candidate rather than the one that shipped, and hid that candidate's score behind the badge. On a real photograph it read webp 229.6 KB WINNER while writing webp-lossless at 344.1 KB, with no way to see WebP had scored 87 against a target of 90.
  • Settings existed twice in the desktop app after the rebuild — two elements sharing each id, with $() silently taking the first.
  • A <b> painting at weight 700, and a weight gate that only checked one of the two app layers.
  • Faces served as application/octet-stream; a /favicon.ico answering 403 on every launch.

Drift closed

The destination table existed in four places by hand. It is now generated from imgcompress/destinations.py by tools/gen_destinations.py and committed, with CI failing on a stale copy — after the hand-written copy drifted from its reference within an hour of being created. The design system is synced into the desktop app the same way.

Four checks that reported success while checking nothing

Recorded because the pattern matters more than the instances: a byte snapshot with a hand-pinned frame size, an AVIF corpus skip that still printed VALIDATED, parser assertions that could match zero lines, and a diff against a file the job had not written yet. Two were caught in review, one by a file timestamp.

CONTRIBUTING.md now carries the rule that came out of it: every new gate must be observed failing — break the thing it watches, watch it go red, restore, and say so in the commit. Everything added here was verified that way.

Verified locally

  • 90 Python tests (24 before this work)
  • 76 browser end-to-end assertions
  • 10 browser gates, including the desktop app driven in real Chrome
  • Both byte-comparison snapshots identical — phases 2 through 6 moved no compressed output at all
  • Three generators current, ruff clean

Not finished, and stated plainly

  • The installers have never been built. That needs macOS and Windows runners, and signing needs certificates only the owner can obtain — Windows OV keys have required FIPS 140-2 L2 hardware since June 2023, so a .pfx in a secret is no longer possible. docs/PACKAGING.md says what is required.
  • The Figma probe has never run inside Figma, which is its entire point. It was verified in headless Chrome, which proves the code works and nothing about Figma's sandbox.
  • This PR is the first time CI has run on any of it.
  • web/sitemap.xml lists neither new page.

🤖 Generated with Claude Code

SyedSaribSultan and others added 11 commits August 7, 2026 15:04
The product's central claim is that the browser scores an image the same way
the Python reference does. Nothing enforced that. ss2_validate.mjs existed and
had to be remembered, which means it was one distracted afternoon away from
never running again — and a drift there is the kind of break nobody notices,
because the app keeps working, it just stops being right.

The job regenerates the vectors from the reference implementation and holds
the JS port to them. It runs on every pull request rather than only ones that
touch ss2.js: a path filter would miss the case that actually worries us, which
is quality.py or a pinned dependency moving the numbers out from under a file
nobody edited.

make_ss2_vectors.py now survives a Pillow built without libavif — most Windows
wheels are — and says out loud that the twelve AVIF pairs are missing rather
than quietly shrinking the corpus and still printing VALIDATED. CI tries to
install pillow-avif-plugin for the full set and is allowed to fail.

Locally: 48/48, mean |Δ| 0.0042, worst 0.0177 against a tolerance of 0.25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The default was `figma`. It capped every image at 4096px and refused WebP,
which is correct for someone exporting into a design file and wrong for
almost everyone else: a person compressing a photograph for their website
silently got no WebP, for a reason about Figma's plugin API that had nothing
to do with them. The restriction was researched and right. Making it the
default for everybody was not.

There were also two settings for one idea — `--preset` chose size and quality,
`--target` chose the format list — and both were named after a product or a
technical concept rather than after something a person knows about themselves.

One list replaces both, named after the only question somebody can answer
without knowing anything about compression:

  web        all formats, 2560px, match 90   <- the new default
  documents  JPEG/PNG only, 4096px enforced, match 90
  email      JPEG/PNG only, 1920px, match 88
  thumbnail  all formats, 512px, match 85
  original   all formats, never resized, match 95

`documents` inherits every restriction `figma` had, because the restriction is
the feature — those tools re-encode WebP to PNG on import, so a 40 KB file
becomes a multi-megabyte one inside the saved document. What changed is who
pays for it: the people actually sending images there.

destinations.py is the single table. It imports nothing from the rest of the
package because three other engines mirror it, and a table with logic in it is
a table that cannot be mirrored. The desktop UI now builds its list from the
server rather than holding a fourth copy of five numbers that must agree.

Picking a destination applies all three of its numbers in both interfaces.
Setting only the format list would make "Thumbnail or avatar" mean nothing but
a shorter list and leave the person to discover that two more controls in
Advanced needed changing for it to do what it says.

AVIF becomes a Python encoder, feature-detected — Pillow only carries it where
the wheel was built against libavif, so on most machines nothing changes. It
is what lets the table be literally the same in all four places rather than
"the same except Python."

Old names keep working: --preset is a synonym for --for, figma resolves to
documents, archive to original, and the CLI says when you have used one.

Measured: bench.mjs is byte-identical on both documents and web. It now pins
the size cap rather than taking the destination's, because camera-12mp.jpg is
4000x3000 and letting the destination move the frame would mean a moved byte
no longer said which change moved it. 33 Python tests, 72 e2e asserts,
ss2_validate, verify_tokens, verify_fonts and four probes all pass.

Two behaviour changes worth stating plainly: `--for documents` resizes to
4096px where `--preset figma` resized to 2560px, and `--preset thumbnail` moves
from 800px/80 to 512px/85. Both follow the destination table. The README's own
argument is that the 2560 cap saves more than the encoder does, so `-m 2560`
remains the better setting for anything bound for a canvas.

One e2e assertion changed rather than being fixed: "ui winner is png8" was
pinning the old default, not the promise. Flat artwork wins on a palette or
lossless format and which one depends on what the destination allows; it now
asserts the winner is the smallest version that passed and never a lossy photo
codec, which is stronger and destination-independent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old `figma` preset had a default and a ceiling: it downscaled to 2560, and
separately clamped at 4096 for people who explicitly asked for more. That is
why the original code said the clamp applied *regardless*. Collapsing them into
one line handed the ceiling over as the everyday setting, so every design-asset
compression would have shipped roughly 2.5x the pixels it should - and
downscaling saves more than the encoder does.

  max_dimension 2560   the everyday frame, same as web
  hard_cap      4096   clamps even an explicit -m 8000

It clamps rather than refuses. The intent behind `-m 8000` is reasonable; the
destination simply cannot carry it, and an error would send someone off to find
a number the tool already knows. Tested in both directions: 8000 comes back as
4096, 800 stays 800.

bench.mjs caught this the moment it happened and it was misread as a test
isolation problem - the frame got pinned so the comparison stayed clean. Right
instinct, wrong moment: it isolated the variable and certified a configuration
nobody would ever run, which is worse than a red gate. The pin is gone. The two
destinations agree on 2560 again, the format list is genuinely the only thing
that differs, and the snapshot is byte-identical at the real defaults.

Commit 1 closed one drift surface and commit 2 opened another: the destination
table now exists in Python, worker.js, app.js and the markup, with nothing
checking that they matched. That is what ss2.js had before this week, and it
bit within the hour - app.js was already claiming 4096 for documents and 85 for
thumbnail while Python said 2560 and 80, so every browser compression would
have used numbers the reference had rejected, silently. test_destination_parity
parses each file and compares every value. Verified by injecting both a dropped
format and a wrong frame size and watching it fail, then restoring.

The CI parity job could also weaken itself without saying so. pillow-avif-plugin
was allowed to fail; if it had, the run would have used 48 vectors instead of
60, printed VALIDATED, and shown the same green tick with AVIF parity untested
from then on - exactly the trap the AVIF handling was written to avoid. The
install is now required, and check_ss2_corpus.py asserts the vector count and
codec coverage rather than printing a warning into a passing log nobody reads.

The e2e assertion loses its second clause and gains a better one. "Never a
lossy photo codec" was the same species of guess as the "winner is png8" it
replaced - on flat artwork a high-quality lossy WebP beating palette PNG is the
comparison working. In its place, completeness: every format a destination
permits must appear in the results, so a silently dead encoder cannot hide
behind "smallest passing candidate", which stays true of four candidates when
there should have been six. web tries all six; documents tries exactly three
and reaches for none of webp, webp-lossless or avif.

thumbnail keeps 512 and goes back to quality 80. Nothing in the history records
why 800 was chosen - it arrived in the initial import - so 512 is the half that
can be argued for, and raising the target was a second change with no reason
behind it. Artefacts are less visible at a smaller size, so if anything it
could fall.

BENCHMARK.md regenerated rather than relabelled, and came out byte-identical:
bench_web_out forces the frame to 0 and feeds already-normalised references, so
no destination's own size ever reached those numbers. Confirmed, not assumed -
the first diff was run against a file the job had not written yet.

Also corrected the landing page, which still told visitors and search engines
that the default target is Figma.

48 Python tests, 76 e2e assertions, both byte snapshots, verify_tokens,
verify_fonts and three probes all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four checks on this branch reported success while checking nothing: a byte
snapshot with a hand-pinned frame, an AVIF skip that still printed VALIDATED,
parser assertions that could match zero lines, and a diff against a file the
job had not written yet. Two were caught in review, one by a file timestamp.
That is the pattern this commit is aimed at, in five places.

**The copies are gone.** tools/gen_destinations.py writes web/destinations.js
from imgcompress/destinations.py. worker.js imports it, index.html loads it
before app.js, and the Format control's options are rendered from it rather
than typed into markup. The generated file is committed - web/ has no build
step and should not grow one - and CI regenerates it and fails on any diff, so
the commit is the check. The parity test now guards the generator: no consumer
may hand-write a destination's name, frame size or format list. It found one
more copy while being written, in app.js's initial state, where a stale value
would have been wrong for exactly the people arriving for the first time.

**Observed failing is now a rule**, in CONTRIBUTING.md with the table of four
above, and applied to everything new here:
  - generator --check: made stale, watched it exit 1, restored
  - generator --check: file deleted, watched it exit 1, restored
  - CRLF working copy: watched it wrongly report stale, fixed, re-checked
  - parity: injected a dropped format and a wrong frame, watched three fail
  - corpus guard: nine tests, six of which assert a red

**The clamp announces itself.** `-m 8000 --for documents` printed "up to
8000px" and produced 4096 - a dimension changing without saying so, which is
the defect this whole rework exists to remove, surviving on the override path
because that path is rarer. It was also a third copy of the clamp rule, kept
only to print a number. destinations.effective_limit is now the one
implementation; the engine and the CLI header both call it, so they cannot
disagree. The header prints the real limit and names the destination that
clamped it.

**The guard has a guard.** check_ss2_corpus.py was verified by hand, which is
the posture ss2_validate.mjs was in before this branch started.
tests/test_corpus_guard.py covers it, including the argparse bug it shipped
with: action="append" adds to a list default rather than replacing it, so
--require-codec jpeg meant "jpeg and the three defaults" and the narrowing path
had never run. Swept the repo for other instances; -f/--format has no list
default and is safe.

**.gitattributes** pins the tree to LF. This repo is written with
core.autocrlf=true, so a fresh Windows clone gets CRLF while the generator
emits LF: --check called a current file stale over a diff that looked empty
because every difference was invisible. Pinned at checkout and tolerated in the
comparison, because one of those can be lost. Binary assets marked so git
cannot corrupt a fixture and make it look like an engine regression.

66 Python tests (24 before this branch), 76 e2e assertions, both byte snapshots
identical at the real defaults, no console errors under the production CSP with
the new script tag, verify_tokens, verify_fonts and three probes green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven invented words for three ideas meant it was possible to look at this
product and not know what it was telling you. One concept, one word, everywhere
a person can see it - the browser app, the desktop app, the CLI, every error
message and the README.

  bake-off              -> the comparison
  candidate             -> version
  floor / quality floor -> your target / minimum visual match
  passes, still passes  -> close enough to the original
  survives              -> wins
  untouched             -> left exactly as it is
  force a format        -> always use
  redo just this image  -> try different settings
  SSIMULACRA 2 82.8     -> visual match 83 out of 100

The measure's real name moved into the details panel. Which measure produced
the number is a fact about our implementation; how close the result came is the
fact somebody is here for. The SSIM fallback keeps its name, because that scale
runs 0-1 and calling it the same thing would mislead.

What the tool does is now described as a benefit rather than as machinery, in
both the landing page and the README.

Every version that lost says why, in one sentence: bigger than your original,
too different from it (with both numbers), lost too much colour detail, or
close enough but larger than the one chosen. A list of rejects with no reasons
showed the machinery working without saying anything. The reason for whichever
version is on screen is shown under the row, not hidden in a tooltip.

Error messages say what happened and what to do next, with no apology, no
blame, and no error code as the headline.

"How this was measured" is written for a person, and carries the fact that
makes this tool beat the obvious alternative: colour is never thrown away,
because matching the same quality with it discarded needed setting 97 instead
of 76 - a file 3.8x larger.

Strings only, no logic. Both byte snapshots identical, 76 e2e assertions, 66
Python tests, all probes and design gates green. verify_tokens caught one
invented CSS token (--oz-leading-normal) in the new rule; replaced with the
real --oz-leading-body-xs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alues

There were two interfaces and they looked like two products. web/ rendered from
a token layer with an automated gate. The desktop app had its palette baked into
the file - its own greys, its own brass, three corner radii, two transition
shorthands, a system-font stack - and nothing checked any of it. That is the
real answer to "how do I get consistency": not a component library, one
interface sitting outside the gate.

The token layer and the faces are now copied into imgcompress/webui/ by
tools/sync_webui_assets.py and committed, same pattern as web/destinations.js:
no build step, CI fails on a stale copy. The desktop app's private palette is
gone entirely, and it uses the browser app's own --app-* alias names, so the two
are one product rather than two that share a name.

Motion is enforced rather than merely available. The token layer already shipped
a closed set; what was missing was anything rejecting a value outside it.
verify_tokens now fails on a hand-typed duration or curve, on `transition: all`,
and on any transition of a layout property - which caught three progress bars
animating `width`, making the browser recompute layout on every frame of every
bar. They scale a transform now.

I did not add the --oz-motion-* set the brief asked for. The token layer already
has --oz-duration-*, --oz-ease-* and the --oz-spring-* pairs, so a parallel set
would be the duplication this work exists to remove - and --oz-ease-exit already
exists with a different curve, so redefining it would silently change every exit
animation in the product. The brief's intent, one closed set with a gate, is met
by enforcing the set that is already there. Say the word if you want those
specific numbers and I will change them at the source.

Two real bugs, both found by looking rather than by reasoning:

The desktop app badged a REJECTED version as the winner. Its list marked
Math.min(bytes) rather than the format that shipped, and hid that candidate's
score behind the badge. On a real photograph it read `webp 229.6 KB WINNER`
while writing webp-lossless at 344.1 KB, with no way to see WebP had scored 87
against a target of 90 - the exact bug core.py fixed in the engine, reappearing
in the picture of it. Found in a screenshot taken for the "same product" check.

The desktop app was one 403 from rendering in Times New Roman. A <link> cannot
carry the query string the page was opened with, so the token check refused the
app's own stylesheets and Chrome dropped them for a JSON MIME type. Static
assets under /webui/ are served before the token check now - package files with
no user data, loopback-Host still enforced, token still gating every API route
and every image. Found by the new runtime gate on its first run, with every
static check green throughout. Which is the whole argument for having it.

probe_a11y and probe_mobile can fail now. Both printed measurements and exited 0
whatever they said, so running them carried almost no information - and it
blocked Phase 4, whose criteria they are meant to enforce. probe_mobile measures
at 375px, not 390.

Observed failing, all of it: the layout-property rule and the literal duration,
literal curve and `transition: all` rules each injected and watched go red in
both app layers; the copy tool broken by editing, deleting and CRLF-ing a copy;
probe_mobile against a forced overhang; probe_a11y against a stripped
accessible name. All restored, all green after.

88 Python tests (24 before this branch), 76 e2e assertions, both byte snapshots
identical, nine browser gates green. Zero output bytes moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
probe_zoom printed a geometry table and exited 0 whatever the table said - the
last gate in the suite that could not fail. It matters more than most here: the
comparison view is what the product is for, and zoom geometry is exactly what a
layout rebuild disturbs. A gate that cannot fail is no help during the change
most likely to break it.

Written against the promise rather than the mechanism, so it survives the layout
being rebuilt around it:

  frame centre - pan == stage centre, at every scale

That is the fault the transform-centring exists to prevent. CSS alignment
silently switches from `center` to `start` once an item outgrows its container,
so grid centring snapped the frame to the top-left the instant you zoomed past
the stage and the rest of the picture hung off the bottom - the original
"scrolling takes me to the top and I have to drag a long way back". Plus three
downstream views of the same fault: the stage holds its height, the document
never gets taller, the page never scrolls.

First attempt at this asserted only the stage's height, and the fault I injected
to check it - height:auto on the stage - could not move a grid item with
overflow:hidden, so it passed and proved nothing. Caught it, went and read how
centring actually works, and asserted that instead. Verified by removing the
50% offsets: five steps report "off by -539,-159" and it exits 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The proof took about a quarter of the pixels. Ten bands surrounded it: a
toolbar, an Advanced drawer, a progress hairline, a 324px rail, a file-identity
row, a canvas bar, the readout, a two-pane details band and a sticky footer.
Quality was editable in four places and format in three. There were six ways to
get a file out behind four buttons. "Deeper" opened downward, rightward, upward
and as a tooltip, depending on which deeper you meant. And waiting was a
skeleton of the finished dashboard, which reads as broken more often than it
reads as loading.

What you are doing now decides what is on screen:

  #view-working   work happening    the untouched original, one sentence, one
                                    bar, one estimate, and the format being
                                    measured right now
  #view-single    one image         the comparison, full bleed, two floating
                                    bars: mode and zoom above, the result and
                                    Download below - four things, no more
  #view-list      several, none open  the list, full width, one row per image
                                    with its two sizes, the saving drawn to the
                                    batch's scale, and the format that won
  #panel          anything deeper   one drawer, from one edge, holding every
                                    version tried, how it was measured, every
                                    setting, the overrides, copy, remove and
                                    the report

Measured: the stage is 802px of an 860px window, against a brief asking for 60%
of the area.

Every load-bearing id kept its name through the move, because the ids name
concepts this rebuild keeps - the stage, the viewport, the narration, the
chips - and the suite is written against those. Three went: adv-btn (an
Advanced disclosure inside a drawer would be a disclosure inside a disclosure),
details (the band is the panel now) and insp-dims (the same fact was being said
twice).

`opened` and `selected` were briefly two variables for one idea. Merged: null
means the list, an id means that image, and back clears it. The distinction was
invisible to the person and the only thing it bought was a way for the two to
disagree.

Every gate that broke was classified before it was touched:
  probe_flow "the settings bar lives in the toolbar" - DOM ancestry of an
    arrangement that no longer exists. Rewritten to the claim worth keeping:
    one of each control, in the one panel, with nothing behind a second
    disclosure inside it.
  probe_flow "chips sit above the numbers" - an absolute vertical relation
    between two containers. Rewritten to what still has to be true: every chip
    is visible and hittable where it sits.
  probe_mobile, probe_a11y, e2e - instruments reaching for controls where they
    used to be. Re-pointed to open the image and the drawer, which is what a
    person does. The claims are untouched.

Three real defects the gates caught, none of which a code read would have:
  - the floating bars did not fit a phone; the zoom cluster ran to 457px on a
    375px screen (B2, binding)
  - the toast covered the floating bottom bar (B4, binding)
  - probe_theme's control-height check had quietly shrunk to ONE control, since
    it skips anything with offsetParent === null and the controls had moved
    into a shut drawer. It was passing because a single value trivially shares
    a height with itself. It now opens the panel and asserts the count, so it
    cannot go vacuous again.

And two from looking at a screenshot rather than the code: chips overflowing
the 420px drawer, and #s-time still saying "candidates" - a Phase 2 miss.

Zero output bytes moved: both snapshots identical. 88 Python tests, 76 e2e
assertions, ten browser gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 rebuilt the browser app and left the desktop one as it was: a fixed
two-column grid where a 330px rail held the same list whether it had one row in
it or eighty, and the comparison - the product's entire argument - lived in
whatever was left. Below it, a two-pane details band and a sticky footer. Same
product, two shapes.

It now has the same four states, chosen the same way from the same two facts:

  #view-working   work happening      the untouched original, one sentence,
                                      one bar - not a skeleton of the result
  #view-single    one image           the comparison full-bleed, two floating
                                      bars, four things in the bottom one
  #view-list      several images      the list full width, a row that says
                                      everything: both sizes, the saving drawn
                                      to the batch's scale, the format that won
  #panel          anything deeper     one drawer, one edge, holding the
                                      versions, how it was measured, every
                                      setting, the overrides and remove

`selected` is the whole view state - null is the list, an id is that image.
`showInspector` is gone; there is no second flag to disagree with the first.
Every desktop-only feature kept working: Watch folder, Add folder, the native
picker and Save to are each in the one place they belong now.

Two real defects, both found by looking at the rendered page rather than the
code:

Settings existed TWICE. The toolbar had Going to, the visual match and the size
cap; the panel now has them too - so the document carried two elements sharing
each id and $() silently took whichever came first. The toolbar copies are gone.
Settings appearing in exactly one place is the point of the phase, and the
rebuild had briefly made it worse.

A <b> was painting at weight 700. The desktop stylesheet never writes 700, but
the user agent does, and web/app.css has carried the override for a while while
this file had not. verify_tokens.mjs could not see it either: its weight loop
ran over app.css and index.html and not over the desktop layer, so a ceiling
checked in one of two app layers had a hole in it. Loop widened, watched it fail
on an injected 800, restored.

Also from the workflow: tests/web/probe_states.mjs, 89 assertions over the six
states phase 4.4 names - one image, several, every one failed, nothing beat the
original, a 60-character filename, and a damaged file among good ones. It
defines "designed" once as five measurable things and holds every state to all
five, rather than arguing six separate opinions. The comparison measures 93% of
the screen area against a brief asking for 60%. Two fixtures added for it,
including a name asserted to be exactly 60 characters so it cannot drift to 59
without the build noticing.

Zero output bytes moved: both snapshots identical. 90 Python tests, 76 e2e
assertions, ten browser gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 5. The interface was showing the workings - lists of versions, scores,
quality numbers - which is not the same thing as showing the evidence. Five
additions, and one of them is a correction rather than a feature.

RESIZING AND COMPRESSING ARE SAID APART. When an image is resized, part of the
saving came from throwing pixels away, and the visual match sitting beside the
total only ever measured the compression. Rolling them into one percentage lets
a big number stand on work the score did not check.

The resize's share is stated in pixels, not in bytes. A byte figure for "what
this would have weighed at full size" cannot be had without encoding it at full
size too, and deriving one from the pixel ratio would be exactly the guess this
project refuses to make everywhere else. So: the frame went from A to B, that is
N% fewer pixels, compression did the rest - and the sentence ends in the button
that goes and finds out for real.

That button needed effectiveSettings to carry a per-image frame size. It carried
format and quality and not the third, so "redo at the original size" had nothing
to ask for. All three of a destination's numbers can be overridden for one image
now, which is the shape the override always implied.

THE CHIPS SAY THEY ARE FREE. Every version already exists, so switching is a
relabel, not a re-run. Nobody clicks a button they believe costs half a minute.

THE TRANSPARENCY CHECK IS VISIBLE WHEN IT RUNS. Transparent artwork is compared
over a dark AND a light background with the worse result counting, because a
halo you cannot see on white is still a defect. As far as we know no other
compressor does this, and it was invisible.

A COMPARISON PAGE, generated. tests/BENCHMARK.md holds the strongest argument
this product has, in a format almost nobody will read: six tables of ten
strategies addressed to somebody already convinced. web/compare.html is the same
numbers written for a person, produced by tools/gen_compare_page.py from
tests/benchmark.json with --check in CI, so the page cannot claim one thing while
the measurement says another. Its headline figures are computed, not typed: 20 of
22 fixed-quality defaults miss the target, and imgcompress is smallest-or-tied on
6 of 6 images. It publishes the loss too - the desktop quantizer beating the
browser one on flat artwork gets its own section, near the top, because a
comparison that only shows its author winning is one nobody should believe.

Also: `Now` became `New size`, a vocabulary miss from the previous phase found by
looking at a screenshot.

Zero output bytes moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 6, plus the one bug researching it uncovered.

THE FORK BOMB. compress_tree uses a ProcessPoolExecutor and nothing called
multiprocessing.freeze_support(). Under spawn - always Windows, the default on
macOS - each worker re-executes the program to import what it needs. Frozen,
there is no python to re-execute: the child runs the application's own executable
again, starts a whole new imgcompress, and opens a pool of its own. A folder of
images becomes a fork bomb. It hid because compress_tree takes a single-process
path for one job, so every one-image smoke test passed. Two tests: the call is
still there, and a real three-worker pool still runs - a guard over a path
nobody exercises protects nothing.

INSTALLERS. packaging/imgcompress.spec plus .github/workflows/release.yml, built
on tag, one per (OS, arch), onedir. Four things the research found had to be
designed for, none of which fail loudly:
  - every optional engine is a try/except import, so a bundle that cannot load
    one does not crash - it quietly compresses with weaker built-ins. So
    `imgcompress --check` is a release-BLOCKING gate in the workflow, not a
    diagnostic.
  - imagequant and mozjpeg are cffi out-of-line modules whose real import
    happens in C, invisible to PyInstaller. They work on Windows by accident
    (pywebview pulls pythonnet which imports cffi) and die silently on macOS.
    hiddenimports carries _cffi_backend.
  - zopflipy is delvewheel-patched and its DLL directory is not where the guard
    looks once frozen: it loads on a dev box with the VC++ redist and fails on a
    clean Windows install, which is exactly the machine the brief names.
  - wheel coverage forces the targets: Windows x64, and macOS arm64 and x86_64
    separately. universal2 is impossible without hand-lipo-ing mozjpeg.

Signing is present and conditional, not pretended. Windows OV keys have needed
FIPS 140-2 L2 hardware since June 2023 so a .pfx in a secret is no longer
possible, and notarisation needs an Apple Developer ID. docs/PACKAGING.md says
precisely what the owner must obtain, and that scipy is 134 MB of the 211 MB
dependency total for one gaussian_filter call.

A DOWNLOAD PAGE. web/download.html: three real builds, all visible, the likely
one hinted rather than filtered, pointing at the releases page rather than
fabricated file URLs. It tells the truth about the unsigned-build warning at the
buttons, not only further down, because somebody who has already clicked is not
reading the bottom of the page.

THE FIGMA SPIKE. Answer: technically yes, and the product idea is not the one we
started with. WebAssembly runs in the plugin iframe; blob-URL workers work since
Aug 2023; Uint8Array is the one binary type that crosses the bridge and it is
exactly what exportAsync returns and createImage accepts; ~6 MB inlined fits the
reported ceiling. But there is NO export hook - a plugin cannot post-process
Figma's own Export, so "compress on export" has to mean the plugin owns the
flow - and createImage takes PNG/JPEG/GIF only, so mozjpeg and oxipng can go back
into the document while WebP and AVIF can only leave as a download. That is two
products.

The decisive unknown is memory, and it is not answerable from here, so
spike/figma-probe/ is a plugin that goes and asks: WebAssembly in both realms,
blob and data workers under allowedDomains none, OffscreenCanvas and
createImageBitmap inside the worker, SIMD, a real instantiate from base64, and a
timed multi-megabyte round trip. It also calls figma.on("export") with controls,
turning the no-export-hook claim from a reading of the docs into an observation.
docs/figma-plugin-spike.md separates what is documented, what is measured in this
repo, and what nobody has verified - and says plainly that the honest outcome may
be "not yet".

Version 2.7.0 across pyproject, __init__, the footer and the structured data. The
default destination changed, thumbnail changed values and --preset/--target
merged, so a minor bump with documented behaviour changes.

90 Python tests, 76 e2e assertions, ten browser gates, both byte snapshots
identical, three generators current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
image-compressor Ready Ready Preview Aug 10, 2026 6:41am
imgcompress Ready Ready Preview Aug 10, 2026 6:41am

First CI run on any of this, and it failed everywhere except ubuntu/3.13 - which
is the version matrix earning its keep, because every one of these passed on
this machine.

Three real portability bugs, none of them findable locally on 3.13:

`Path.read_text`/`write_text` only learned `newline=` in 3.13 and 3.10. The
package supports 3.9, and five files used it - the three generators plus two
test modules - so eight of eleven jobs died on a TypeError before reaching a
single assertion. `Path.open` has always taken it; that is what they use now.

`unittest.TestCase.enterContext` arrived in 3.11, and test_corpus_guard used it
in setUp, so its whole class errored on 3.9. addCleanup instead.

The `generated` job installs nothing, by design: "is this committed file
current?" is answered by two files on disk and should not need a dependency
tree. But `from imgcompress import destinations` runs the package __init__,
which imports the engine, which imports Pillow - so the job failed in eight
seconds on an ImportError that had nothing to do with the file it was checking.
destinations.py was deliberately written to import nothing from the rest of the
package, precisely so it could be read on its own; gen_destinations now loads it
directly by path. Verified by running the tool with PIL, numpy and scipy blocked
at the import hook: exit 0.

The whole `generated` job is dependency-free now, which is the right shape for a
check that runs on every pull request and should never be able to go red because
a release of Pillow broke.

Also swept the new files for other post-3.9 API use and found none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit fixed five of six. The two it missed were split across
lines, and the grep I checked with was single-line - so it reported clean while
CI reported a TypeError on the same file. Verified this time with an AST walk
over every call to read_text and write_text in tests/ and tools/, which cannot
be fooled by where the line breaks fall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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