Roadmap phases 1-6: destinations, plain English, one design system, the layout rebuild, the proof, and shipping - #1
Merged
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Eleven commits, stacked as four branches, all present here.
mainis untouched until this merges.The six phases
figmatoweb, so somebody compressing for a website stopped silently losing WebP for a reason about Figma's plugin API.imgcompress --checkas a release-blocking gate, a download page, and a Figma spike with a probe.Version 2.7.0. The default destination changed,
thumbnailchanged values and--preset/--targetmerged, so it is a minor bump with documented behaviour changes. Older names (figma,archive) still resolve.Bugs found and fixed along the way
compress_treeuses a process pool and nothing calledmultiprocessing.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.<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.webp 229.6 KB WINNERwhile writingwebp-losslessat 344.1 KB, with no way to see WebP had scored 87 against a target of 90.$()silently taking the first.<b>painting at weight 700, and a weight gate that only checked one of the two app layers.application/octet-stream; a/favicon.icoanswering 403 on every launch.Drift closed
The destination table existed in four places by hand. It is now generated from
imgcompress/destinations.pybytools/gen_destinations.pyand 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 adiffagainst a file the job had not written yet. Two were caught in review, one by a file timestamp.CONTRIBUTING.mdnow 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
Not finished, and stated plainly
.pfxin a secret is no longer possible.docs/PACKAGING.mdsays what is required.web/sitemap.xmllists neither new page.🤖 Generated with Claude Code