diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1d24bf4..72c3897 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -113,9 +113,6 @@ jobs:
# people edit is in web/, the copy is committed so a pip install needs no
# build, and a stale copy is a red build rather than two visual identities.
- run: python tools/sync_webui_assets.py --check
- # The comparison page. Generated from tests/benchmark.json so the page
- # cannot claim one thing while the measurement says another.
- - run: python tools/gen_compare_page.py --check
lint:
runs-on: ubuntu-latest
diff --git a/GUIDE.md b/GUIDE.md
index a3250a3..b0d95cb 100644
--- a/GUIDE.md
+++ b/GUIDE.md
@@ -66,8 +66,10 @@ are not; the browser engine has had it since the WASM codec tier landed.
Which candidates a run is allowed to use comes from `destinations.py`, not from
here. **That is the single place the format policy lives**, and it is shared with
-`web/worker.js`, `web/app.js` and the desktop UI — the same five entries with the
-same numbers in all four.
+`web/worker.js`, the browser UI's `web/js/` modules and the desktop UI — the same
+five entries with the same numbers in all four. The browser's copy is
+`web/destinations.js`, generated by `tools/gen_destinations.py` and committed; CI
+regenerates it and fails on a diff.
`JpegEncoder` is hardcoded to 4:4:4 chroma. That's deliberate: on saturated
content, matching 4:4:4's quality-76 score with 4:2:0 required quality 97 and
@@ -259,34 +261,57 @@ is how a "no dependencies to compile" promise quietly breaks.
# The web version (`web/`)
A static port of the same engine that runs entirely in the browser, deployed at
-[imgcompress-app.vercel.app](https://imgcompress-app.vercel.app). Five files:
-`index.html` (landing + app shell), `app.css`, `app.js` (UI, worker pool, zip
-download), `ss2.js` (the metric), `worker.js` (the engine: ladder bisection,
-the bake-off, dual-backdrop transparency scoring, the never-bigger rule — a
-port of `quality.py` + `core.py` + `encoders.py`).
+[imgcompress-app.vercel.app](https://imgcompress-app.vercel.app).
+
+**The engine**, unchanged and independent of any interface: `worker.js` (ladder
+bisection, the bake-off, dual-backdrop transparency scoring, the never-bigger
+rule — a port of `quality.py` + `core.py` + `encoders.py`), `ss2.js` (the metric)
+and `destinations.js` (generated from `destinations.py`).
+
+**The interface**, one page and nothing else. `index.html` is the dashboard;
+`web/css/` holds six stylesheets, one per concern, with every colour and space
+defined once in `base.css`; `web/js/` holds thirteen ES modules with a strict
+dependency direction — `format` and `dom` depend on nothing, `state` holds the
+store, `engine` owns the worker pool and the message contract, `queue`/`compare`/
+`facts` only render, `render` schedules them, and `main` is the only module that
+binds an event listener.
+
+The interface deliberately does **not** read the `--oz-*` token layer. That layer
+now serves the desktop app alone; the browser app was reset to a baseline of
+system faces and a six-name palette of its own. See *One design system* below for
+what is still shared and what is not.
+
+There is one page. The marketing sections, the `/compare` and `/download` pages,
+the theme switch, the synthetic demo, the lifetime savings counter and the
+CSV/JSON report export were removed: none of them was part of compressing an
+image.
### The first five seconds after a drop
-There are two pages, not three: landing → studio. A drop starts the work with
-nothing to press. The sequence that makes that acceptable is an ordering, and
-the ordering is load-bearing — `probe_flow.mjs` asserts each step, because
-every one of them is a thing someone will later be tempted to collapse.
-
-1. **The untouched original is painted first.** `addFiles()` calls
- `renderNow()` — synchronously, not `scheduleRender()` — so the studio and
- the original's `src` are in the document immediately, and then holds
- `dispatch()` until the *next* animation frame so the browser has actually
- painted before an encoder is asked for anything. It costs a frame. Do not
- "optimise" it away: the difference between *here is your image, now watch*
- and *something happened to my file* is entirely in that ordering.
-2. **A sentence, not a spinner.** `WORKING_LINE` in `app.js` is the landing
- page's promise in the present tense. It is copy that ships verbatim — the
- E2E compares it character for character — because its whole job is to be
- recognisably the same claim that got the person to click *Choose images*.
-3. **The result appears beside the original.** `mode` starts at `"split"` and
- the `.wipe` animation sweeps the compressed half in over the original.
- The original never leaves the stage.
-4. **The result state is the control surface.** See below.
+There is one page, and a drop starts the work with nothing to press. The sequence
+that makes that acceptable is an ordering, and the ordering is load-bearing —
+`probe_flow.mjs` and `e2e.mjs` assert each step, because every one of them is a
+thing someone will later be tempted to collapse.
+
+1. **The untouched original is painted first.** `addFiles()` in `js/intake.js`
+ calls the three renderers synchronously — not `scheduleRender()` — so the
+ original's `src` is in the document immediately, and then holds `dispatch()`
+ until the *next* animation frame so the browser has actually painted before an
+ encoder is asked for anything. It costs a frame. Do not "optimise" it away:
+ the difference between *here is your image, now watch* and *something happened
+ to my file* is entirely in that ordering.
+ The harness observes this frame through `imgc.holdWork(true)`, which is why
+ that seam exists: `dispatch` is a module binding, so there is no global to
+ stub.
+2. **What is being tried is named, not spun.** `#stage-work` reports the format
+ being measured right now. Never a bare spinner: the wait should be legible
+ rather than merely long.
+3. **The result appears beside the original.** `mode` starts at `"split"`, and
+ both layers live in one `#frame` at natural size so a single transform moves
+ them together. The original never leaves the stage.
+4. **The evidence is on screen, not behind a disclosure.** The chips, the
+ measurements and the per-image override are three blocks in `#facts`, always
+ present. There is no drawer to find.
### Candidates: the chips are the format control
@@ -419,8 +444,12 @@ edit it — it is generated by that repo's `node build/build.mjs`, and a change
here is silently overwritten on the next sync. To change a value, change it
upstream, rebuild, and re-copy.
-`web/app.css` consumes those tokens and hand-types nothing: no hex, no
-`rgb()`, no `cubic-bezier`. Four of the system's rules are load-bearing here:
+`imgcompress/webui/app.html` consumes those tokens and hand-types nothing: no
+hex, no `rgb()`, no `cubic-bezier`. **The browser app no longer consumes them at
+all** — it was reset to its own baseline in `web/css/base.css`, where the same
+guarantee (values defined once, consumed by name everywhere else) is enforced by
+`TheBrowserAppHasOnePlaceForValues` in `tests/test_design_system.py`. Four of the
+system's rules are load-bearing for the desktop app:
* **Adjacent regions never share a surface rung.** Separation is a surface step
or space, never a border — `background` → `surface-primary` (toolbar, queue,
@@ -534,10 +563,12 @@ identical runs, so timings are reported as min-of-N.
Both live in `tests/web/`:
-* `verify_tokens.mjs` — fails on a `var(--oz-*)` the token layer does not
- define, any colour literal in `app.css`, a leftover pre-migration variable, a
- weight above 600 reaching the app layer, and a declared face missing from
- disk.
+* `verify_tokens.mjs` — the desktop app only, since it is the only consumer of
+ the token layer now. Fails on a `var(--oz-*)` the layer does not define, any
+ colour literal in `webui/app.html`, a leftover pre-migration variable, a weight
+ above 600 reaching the app layer, and a declared face missing from disk. The
+ browser app's equivalent rules run in `tests/test_design_system.py`, without
+ Chrome and without Node.
* `verify_fonts.mjs` — loads the real page in Chrome and asserts the six faces
register and parse, that Bricolage and Geist are what actually paint, that
**no rendered element** computes above 600 (checked twice: empty state, then
diff --git a/imgcompress/destinations.py b/imgcompress/destinations.py
index 352da0f..d45ea1e 100644
--- a/imgcompress/destinations.py
+++ b/imgcompress/destinations.py
@@ -12,7 +12,8 @@
and was never told why. One list, named after destinations, is the fix.
This table is the single source of truth for the Python side. `web/worker.js`,
-`web/app.js` and `imgcompress/webui/app.html` carry the same entries with the
+`web/destinations.js` (generated from this file) and
+`imgcompress/webui/app.html` carry the same entries with the
same numbers; if you change one, change all four. `tests/test_compress.py` has
a test per destination so the Python side cannot drift on its own.
"""
diff --git a/tests/test_compress.py b/tests/test_compress.py
index 5250181..97fd877 100644
--- a/tests/test_compress.py
+++ b/tests/test_compress.py
@@ -134,7 +134,7 @@ def test_every_named_format_has_an_encoder(self):
class DestinationTests(unittest.TestCase):
"""The table is a promise about where an image is going. Pin all of it.
- These same five entries are duplicated in `web/worker.js`, `web/app.js` and
+ These same five entries are duplicated in `web/worker.js`, `web/destinations.js` and
the desktop UI, which cannot be checked from here - but the Python side is
the reference, so at least it cannot drift on its own.
"""
diff --git a/tests/test_design_system.py b/tests/test_design_system.py
index 9742467..559802d 100644
--- a/tests/test_design_system.py
+++ b/tests/test_design_system.py
@@ -204,24 +204,96 @@ def test_every_token_it_references_is_defined(self):
self.assertEqual(sorted(used - defined), [])
+WEB_CSS_DIR = WEB / "css"
+
+# The browser app's stylesheets, in load order. base.css is where every colour
+# and space is *defined*; the others may only consume them by name.
+WEB_SHEETS = ("base.css", "layout.css", "controls.css",
+ "queue.css", "compare.css", "facts.css")
+
+
+def _web_css(name: str) -> str:
+ return re.sub(r"/\*.*?\*/", "", _read(WEB_CSS_DIR / name), flags=re.S)
+
+
+class TheBrowserAppHasOnePlaceForValues(unittest.TestCase):
+ """The browser app was reset to a baseline of its own: system faces, a six
+ name palette, no brand layer. That is a deliberate break from the token
+ layer, which now serves only the desktop app.
+
+ A baseline still needs the property the token layer was bought for - values
+ defined once - so it is enforced here directly. base.css defines; every other
+ sheet consumes. Without this the reset would drift back into scattered
+ literals within a few edits, which is the state it was reset out of.
+ """
+
+ def test_every_sheet_exists(self):
+ for name in WEB_SHEETS:
+ with self.subTest(sheet=name):
+ self.assertTrue((WEB_CSS_DIR / name).is_file(),
+ f"web/css/{name} is missing")
+
+ def test_index_links_them_all_with_base_first(self):
+ html = _read(WEB / "index.html")
+ seen = [html.find(f"/css/{name}") for name in WEB_SHEETS]
+ for name, at in zip(WEB_SHEETS, seen):
+ with self.subTest(sheet=name):
+ self.assertNotEqual(at, -1, f"index.html does not link {name}")
+ self.assertEqual(seen, sorted(seen),
+ "base.css must load before the sheets that consume it")
+
+ def test_only_base_defines_colour_literals(self):
+ """A hex outside base.css is a value with no name, and a value with no
+ name is one nobody can change in both themes at once."""
+ for name in WEB_SHEETS[1:]:
+ with self.subTest(sheet=name):
+ found = re.findall(r"#[0-9a-fA-F]{3,8}\b", _web_css(name))
+ # compare.css paints the transparency checkerboard and the
+ # caliper, both of which sit on top of a photograph and must not
+ # follow the page theme. They are the documented exception.
+ if name == "compare.css":
+ continue
+ self.assertEqual(found, [], f"{name} hand-types {found}")
+
+ def test_every_token_used_is_defined_in_base(self):
+ defined = set(re.findall(r"^\s*(--[a-z0-9-]+)\s*:", _web_css("base.css"), re.M))
+ for name in WEB_SHEETS[1:]:
+ used = set(re.findall(r"var\((--[a-z0-9-]+)", _web_css(name)))
+ # Locally-set custom properties, written by JS or by a sibling rule.
+ used -= {"--clip", "--bar-h"}
+ with self.subTest(sheet=name):
+ self.assertEqual(sorted(used - defined), [],
+ f"{name} uses tokens base.css does not define")
+
+ def test_the_page_carries_no_inline_script(self):
+ """The CSP forbids inline script outright rather than allow-listing a
+ hash. An inline
-
-
-
-
- imgcompress
-
-
Every compressor looks good until you hold them all to the same standard
-
-
Comparing file sizes on their own tells you nothing: anything
- can be made smaller by making it look worse. So every strategy on this page
- was searched for the smallest file that still looks close enough to the
- original — the same standard, a visual match of
- {esc(round(floor))} out of 100, for all of them. Then the sizes are worth
- comparing.
-
-
-
-
{h["defaults_missing"]}/{h["defaults_total"]}
-
of the fixed-quality settings people normally reach for — JPEG 75,
- JPEG 85, WebP 75, AVIF 50 — produced a file you could see the
- difference in.
-
-
-
{h["ours_best_or_tied"]}/{h["images"]}
-
images where imgcompress produced the smallest file that still cleared
- the standard, or tied with whatever did.
-
-
-
1
-
image where it loses, to its own desktop build. That row is on this
- page like every other. Read it first if you
- like.
-
-
-
-
Where it loses
-
On flat artwork with few colours, the desktop build has a quantizer the
- browser does not: libimagequant, the engine inside pngquant, which
- is a C library and cannot go in a web page. The browser has its own, and on
- the hardest palette image in this corpus it comes out a couple of kilobytes
- behind.
-
It is a small gap and it is a real one. If your work is mostly flat
- artwork and every kilobyte counts, the desktop
- build is the better tool. Everywhere else the two agree, because they
- measure with the same code — a claim that is
- checked
- on every change rather than asserted.
-
-
How to read the tables
-
-
Searched strategies were given the standard and told to find the
- smallest file that meets it. Fixed settings were simply used as-is,
- which is what picking a quality number does.
-
Match is how close the result came to the original, out of 100.
- 100 is indistinguishable. Below {esc(round(floor))} you would see it.
-
A row marked you would see it is not a win however small it is.
- That is the entire point.
-
-{sections}
-
-
Reproduce it
-
None of this is worth anything if you cannot run it yourself.
-
git clone https://github.com/SyedSaribSultan/imgcompress
-cd imgcompress
-pip install -e ".[full]"
-python tests/bench_vs_alternatives.py # rewrites the data this page is built from
-python tools/gen_compare_page.py # rewrites this page
-
This page is generated from
- tests/benchmark.json, which
- tests/bench_vs_alternatives.py writes. Nothing here is typed
- by hand, so the page cannot say one thing while the measurement says
- another — and continuous integration fails if the two drift apart.
-
-"""
-
-
-def main(argv=None) -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--check", action="store_true",
- help="do not write; exit 1 if the page is out of date")
- args = parser.parse_args(argv)
-
- if not DATA.is_file():
- print(f"FAIL: {DATA.relative_to(ROOT)} is missing. Run "
- "`python tests/bench_vs_alternatives.py` first.", file=sys.stderr)
- return 2
-
- data = json.loads(DATA.read_text(encoding="utf-8"))
- missing = [i["name"] for i in data["images"] if i["name"] not in SUBJECTS]
- if missing:
- # Not a warning. A caption that says nothing is worse than a build error,
- # because it ships.
- print("FAIL: no description for " + ", ".join(missing)
- + " - add them to SUBJECTS in tools/gen_compare_page.py",
- file=sys.stderr)
- return 1
-
- fresh = render(data)
- raw = _read_exact(OUTPUT) if OUTPUT.is_file() else None
- existing = raw.replace("\r\n", "\n") if raw is not None else None
-
- if args.check:
- if existing != fresh:
- print(f"FAIL: {OUTPUT.relative_to(ROOT).as_posix()} is out of date with "
- "tests/benchmark.json.\nRun `python tools/gen_compare_page.py` "
- "and commit the result.", file=sys.stderr)
- return 1
- print(f"{OUTPUT.relative_to(ROOT).as_posix()} is up to date")
- return 0
-
- if existing == fresh:
- print(f"{OUTPUT.relative_to(ROOT).as_posix()} already current")
- return 0
- _write_exact(OUTPUT, fresh)
- print(f"wrote {OUTPUT.relative_to(ROOT).as_posix()}")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/web/app.css b/web/app.css
deleted file mode 100644
index bb2b8a4..0000000
--- a/web/app.css
+++ /dev/null
@@ -1,1966 +0,0 @@
-/* ---------------------------------------------------------------------------
- imgcompress, built on the HeyOz design system.
-
- Every colour, radius, space, type step, shadow and easing below comes from
- `heyoz-tokens.css` as an `--oz-*` token. There is not one hand-typed hex or
- cubic-bezier in this file, which is the system's first rule: values are
- computed in OKLCH upstream and gated for contrast there, so restating one
- here would put it outside the engine.
-
- One layer sits between the tokens and the call sites: a short block of
- `--app-*` aliases for the decisions this app makes *within* the system —
- which three of the eleven radius steps it uses, and how tall a control is.
- The system offers a range; picking the same members of it every time is
- what makes a set of screens look like one product. Add to that block rather
- than reaching past it for a raw step.
-
- Three of the system's rules shape this file in ways worth knowing:
-
- * **Borders do one of two jobs.** `affordance` (the stroke *is* the control —
- an input, a secondary button) or `state` (a focus ring, a selected row).
- Separation is a surface step or space instead, and elevation is shadow in
- light / a lighter surface in dark. Every remaining border here is marked
- with its job.
- * **Spatial travel is written through the multiplier.** `translateY(calc(6px
- * var(--oz-motion-spatial-scale)))`, never a bare 6px, so reduced motion
- can collapse movement while keeping fades. The entrance animations are the
- system's own `.oz-enter-*` classes for the same reason.
- * **Effects springs must not overshoot; spatial springs must.** Colour and
- opacity use `--oz-spring-effects-*`; transform and size use
- `--oz-spring-spatial-*`. Backwards is what "bouncy and cheap" actually is.
-
- The interface stays achromatic. Brand orange is spent in exactly three
- places, and each is the single most important thing in its region: the
- primary action, the badge on the winning encode, and the invitation at the
- end of the narration — which is the one action the result state is trying to
- get someone to notice, and the reason the chips get discovered at all. The
- chips' own size meters are deliberately neutral: the selected ring already
- says which encode is showing and the badge already says which one won, so a
- coloured meter would be a fourth voice adding nothing. Adding a fourth use
- of the accent means taking one of these away.
-
- **Weight ceiling: 600.** Nothing in this app renders above semibold, in any
- instance. Two of the system's steps carry heavier defaults —
- `--oz-default-weight-display` is 800 and `--oz-weight-bold` is 700 — and
- neither is referenced here; the display heading takes
- `--oz-weight-semibold` explicitly instead. Those tokens are not redefined,
- because a token that no longer means what it says is worse than a call site
- that chose a different one. The self-hosted faces are cut to `wght 400..600`
- for the same reason, so a heavier weight has no master to render even if one
- were asked for, and `scratchpad/verify_tokens.mjs` fails the build on any
- weight above 600 reaching the app layer.
- --------------------------------------------------------------------------- */
-
-/* heyoz-tokens.css is linked ahead of this file in the document head rather
- than @imported here: an @import cannot begin downloading until this file has
- parsed, which would serialise two blocking requests. */
-
-/* --------------------------------------------------------------------------- */
-/* app-level aliases: the two things this app has that the system does not */
-/* --------------------------------------------------------------------------- */
-:root {
- /* The comparison stage's transparency checkerboard. Two surface steps, so it
- tracks the theme instead of being a pair of greys. */
- --checker-a: var(--oz-color-surface-secondary);
- --checker-b: var(--oz-color-surface-tertiary);
-
- /* ---- corner scale -------------------------------------------------------
- The system ships eleven radius steps. This app was reaching for nine of
- them, which is not a scale but a shrug: 2px and 4px and 6px corners sat
- side by side where nobody could tell them apart, and a 32px hero panel
- shared a page with 16px cards. Three steps carry every surface here, and
- each says what it is for rather than how big it is. Changing a corner is
- now one edit in one place.
-
- Prefix is `--app-`, not `--ui-`: verify_tokens.mjs reads `--ui*` as a
- leftover from the pre-system palette and fails the build on it. */
- --app-radius-sm: var(--oz-radius-3); /* 6px — controls, chips, thumbs */
- --app-radius-md: var(--oz-radius-4); /* 8px — rows, cards, tiles */
- --app-radius-lg: var(--oz-radius-8); /* 16px — panels, dialogs, hero */
- --app-radius-pill: var(--oz-radius-full); /* tracks, dots, badges */
-
- /* ---- control height -----------------------------------------------------
- Buttons were 38px, selects 32px, and they sit on the same toolbar row, so
- every control was a couple of pixels out from its neighbour. Two heights:
- one for anything on a normal row, one for dense clusters. `.btn.big` keeps
- the system's own touch target. */
- --app-control-h: 36px;
- --app-control-h-sm: 28px;
-
- /* ---- sub-scale gap ------------------------------------------------------
- Deliberately below the system's floor (`--oz-space-1` is 4px). Two things
- need to sit *inside* something without reading as separated: the thumb in
- a segmented track, and rows in a dense list. Five call sites were using
- 2px and one 3px, which is not a decision, it is a typo with a long life.
- Named here so the next one does not invent a seventh value. */
- --app-gap-tight: 2px;
-}
-
-* { box-sizing: border-box; }
-[hidden] { display: none !important; }
-html { scroll-behavior: smooth; }
-body {
- margin: 0;
- font-family: var(--oz-font-body);
- font-size: var(--oz-text-body-sm);
- line-height: var(--oz-leading-body-md);
- font-weight: var(--oz-default-weight-body);
- color: var(--oz-color-content-primary);
- background: var(--oz-color-background);
- -webkit-font-smoothing: antialiased;
-}
-button, input, select { font: inherit; color: inherit; }
-button { cursor: pointer; border: none; background: none; }
-/* The user agent renders b, strong, th and h1-h6 at `bold` (700). This app
- caps at semibold, so the default is replaced rather than left to inherit -
- a stylesheet that never writes 700 still gets 700 without this. */
-b, strong, th, h1, h2, h3, h4, h5, h6, optgroup { font-weight: var(--oz-weight-semibold); }
-a { color: var(--oz-color-content-primary); text-decoration-color: var(--oz-color-content-tertiary); text-underline-offset: 3px; }
-a:hover { text-decoration-color: var(--oz-color-border-brand); }
-
-/* The default ring: anything on a neutral surface, offset is fine. */
-:focus-visible {
- outline: var(--oz-focus-ring-width) solid var(--oz-color-border-focus);
- outline-offset: var(--oz-focus-ring-offset);
- border-radius: var(--app-radius-sm);
-}
-/* On a brand fill an offset ring fills the gap with the page colour and
- vanishes, so those get the inset inverse ring instead. */
-.btn.primary:focus-visible {
- outline: none;
- box-shadow: inset 0 0 0 var(--oz-focus-ring-width) var(--oz-color-border-focus-inverse);
-}
-
-.num { font-family: var(--oz-font-mono); font-variant-numeric: tabular-nums; }
-.micro {
- font-family: var(--oz-font-label);
- font-size: var(--oz-text-label-xs);
- line-height: var(--oz-leading-label-xs);
- letter-spacing: var(--oz-tracking-label-xs);
- font-weight: var(--oz-weight-semibold);
- text-transform: uppercase;
- color: var(--oz-color-content-tertiary);
-}
-.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
-.muted { color: var(--oz-color-content-secondary); }
-.skip {
- position: absolute; left: -9999px; top: 0; z-index: var(--oz-layer-tooltip);
- background: var(--oz-color-fill-brand); color: var(--oz-color-content-on-brand);
- padding: var(--oz-space-3) var(--oz-space-5);
- border-radius: 0 0 var(--app-radius-md) 0;
- font-weight: var(--oz-weight-semibold);
-}
-.skip:focus { left: 0; }
-
-/* ------------------------------ site chrome ------------------------------ */
-/* The surface ladder does all the separating in this file, so it is worth
- stating once. Darkest to lightest, and adjacent regions never share a rung:
- page `background` (the stage, the learn sections) → `surface-primary`
- (toolbar, queue, result panes) → `surface-secondary` (site header, the
- results bar, the advanced tray) → `surface-tertiary` (inputs and fills).
- The header sits a rung above the toolbar directly beneath it; on one rung
- they would read as a single undivided slab. */
-.site-head {
- display: flex; align-items: center; gap: var(--oz-space-7);
- padding: 0 var(--oz-space-7); height: 56px;
- background: var(--oz-color-surface-secondary);
- position: sticky; top: 0; z-index: var(--oz-layer-sticky);
-}
-.brand { display: flex; align-items: center; gap: var(--oz-space-3); text-decoration: none; }
-.brand svg { display: block; }
-.brand b {
- font-family: var(--oz-font-heading);
- font-weight: var(--oz-weight-semibold);
- font-size: var(--oz-text-body-md);
- letter-spacing: var(--oz-tracking-heading-sm);
-}
-.site-head nav { display: flex; gap: var(--oz-space-6); }
-.site-head nav a {
- color: var(--oz-color-content-secondary); text-decoration: none;
- font-size: var(--oz-text-body-sm);
- transition: color var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast);
-}
-.site-head nav a:hover { color: var(--oz-color-content-primary); }
-.head-right { display: flex; align-items: center; gap: var(--oz-space-4); margin-left: auto; }
-.lifetime {
- font-size: var(--oz-text-body-xs); color: var(--oz-color-content-tertiary); white-space: nowrap;
-}
-
-.site-foot {
- display: flex; flex-wrap: wrap; align-items: center; gap: var(--oz-space-3) var(--oz-space-7);
- padding: var(--oz-space-7) var(--oz-space-7) var(--oz-space-11);
- background: var(--oz-color-surface-primary);
- color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-sm);
-}
-.site-foot a { color: var(--oz-color-content-secondary); }
-.foot-privacy { margin-left: auto; }
-
-.noscript {
- margin: var(--oz-space-6) auto; max-width: var(--oz-container-sm);
- padding: var(--oz-space-5) var(--oz-space-6);
- background: var(--oz-color-fill-brand-secondary);
- border: var(--oz-stroke-2) solid var(--oz-color-border-brand); /* job: state */
- border-radius: var(--app-radius-md);
-}
-
-/* ------------------------------- controls -------------------------------- */
-/* .btn's stroke is an affordance: on a secondary button the boundary IS the
- control. The primary button gets its shape from the fill, so it has none. */
-.btn {
- display: inline-flex; align-items: center; gap: var(--oz-space-2);
- padding: 0 var(--oz-space-4); min-height: var(--app-control-h);
- border: var(--oz-stroke-2) solid var(--oz-color-border-primary);
- border-radius: var(--app-radius-sm);
- background: var(--oz-color-fill-secondary);
- color: var(--oz-color-content-primary);
- font-size: var(--oz-text-label-md);
- font-weight: var(--oz-weight-medium);
- white-space: nowrap; text-decoration: none;
- transition-property: background-color, border-color, color, opacity, box-shadow, transform;
- transition-duration: var(--oz-spring-effects-fast-ms);
- transition-timing-function: var(--oz-spring-effects-fast);
-}
-.btn:hover { background: var(--oz-color-fill-secondary-hover); border-color: var(--oz-color-border-primary-hover); }
-/* A press has to register instantly - a button that looks inert while work
- starts reads as broken, however fast the work actually is. This transform IS
- the state, so it keeps its distance under reduced motion and loses only its
- overshoot (the transition runs on a spatial spring, which repoints). */
-.btn:active { background: var(--oz-color-fill-secondary-active); transform: translateY(1px); }
-.btn[disabled] {
- background: var(--oz-color-fill-secondary-disabled);
- color: var(--oz-color-content-primary-disabled);
- border-color: var(--oz-color-border-primary-disabled);
- pointer-events: none; cursor: progress;
-}
-.btn.ghost { background: none; border-color: transparent; color: var(--oz-color-content-secondary); }
-.btn.ghost:hover { background: var(--oz-color-fill-tertiary); color: var(--oz-color-content-primary); }
-.btn.ghost.danger:hover { color: var(--oz-color-content-critical); background: var(--oz-color-fill-critical-secondary); }
-.btn.primary {
- background: var(--oz-color-fill-brand); color: var(--oz-color-content-on-brand);
- border-color: transparent; font-weight: var(--oz-weight-semibold);
- /* Height comes from .btn. A primary that was 6px taller than the secondary
- beside it made every row it appeared in look misaligned; the emphasis is
- carried by the fill and the weight, which is what emphasis is for. */
- padding: 0 var(--oz-space-6);
-}
-.btn.primary:hover { background: var(--oz-color-fill-brand-hover); }
-.btn.primary:active { background: var(--oz-color-fill-brand-active); }
-.btn.primary[disabled] {
- background: var(--oz-color-fill-brand-disabled);
- color: var(--oz-color-content-on-brand-disabled);
-}
-.btn.big { min-height: var(--oz-target-min); padding: 0 var(--oz-space-7); font-size: var(--oz-text-body-md); }
-.btn.sm { min-height: var(--app-control-h-sm); padding: 0 var(--oz-space-3); font-size: var(--oz-text-body-xs); }
-/* Shortcut hints ride on the button that performs the action, which is where
- people actually learn them. */
-/* Recessive, but still legible as a key. With no separation and no outline the
- hint merged into the word beside it and "Split Space" read as the name of a
- mode called Split Space rather than Split, shortcut Space. A hairline at low
- opacity is enough to say "this is a key" without turning the toolbar into a
- row of keycaps. */
-.btn kbd, .segmented kbd {
- background: transparent;
- margin-left: var(--oz-space-2);
- padding: 0 var(--oz-space-1);
- border: var(--oz-stroke-1) solid currentColor;
- border-radius: var(--oz-radius-2);
- color: currentColor; opacity: .45; font-size: var(--oz-text-label-xs);
-}
-.btn.primary kbd { opacity: .72; }
-
-/* -------------------------- segmented control ----------------------------- */
-/* One control, not three loose buttons: the options are mutually exclusive, so
- they read as one thing with a moving selection. */
-.segmented {
- display: inline-flex; padding: var(--app-gap-tight); gap: var(--app-gap-tight);
- background: var(--oz-color-fill-tertiary);
- border-radius: var(--app-radius-md);
-}
-.segmented button {
- display: inline-flex; align-items: center;
- padding: var(--oz-space-1) var(--oz-space-4); min-height: var(--app-control-h-sm);
- border-radius: var(--app-radius-sm);
- color: var(--oz-color-content-secondary);
- font-size: var(--oz-text-body-sm); font-weight: var(--oz-weight-medium);
- white-space: nowrap;
- transition: background-color var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast),
- color var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast);
-}
-.segmented button:hover { color: var(--oz-color-content-primary); }
-.segmented button[aria-pressed="true"] {
- background: var(--oz-color-surface-elevated);
- color: var(--oz-color-content-primary);
- box-shadow: var(--oz-elevation-x-small);
-}
-.segmented button[disabled] { color: var(--oz-color-content-secondary-disabled); pointer-events: none; }
-
-/* ----------------------------- overflow menu ------------------------------ */
-.overflow { position: relative; }
-.overflow .menu {
- position: absolute; right: 0; top: calc(100% + var(--oz-space-2)); bottom: auto;
-}
-.overflow .menu a {
- text-align: left; padding: var(--oz-space-3) var(--oz-space-4);
- border-radius: var(--app-radius-sm); text-decoration: none;
- font-size: var(--oz-text-body-sm); color: var(--oz-color-content-secondary);
-}
-.overflow .menu a:hover { background: var(--oz-color-fill-tertiary-hover); color: var(--oz-color-content-primary); }
-.overflow .menu button { display: flex; justify-content: space-between; gap: var(--oz-space-4); }
-.chev {
- display: inline-block; color: var(--oz-color-content-tertiary);
- transition: transform var(--oz-spring-spatial-fast-ms) var(--oz-spring-spatial-fast);
-}
-[aria-expanded="true"] .chev { transform: rotate(90deg); }
-
-/* Inputs: the stroke is the control's boundary. job: affordance */
-select, .num-input {
- background: var(--oz-color-surface-secondary);
- border: var(--oz-stroke-2) solid var(--oz-color-border-secondary);
- border-radius: var(--app-radius-sm);
- padding: var(--oz-space-1) var(--oz-space-3); min-height: var(--app-control-h);
- font-size: var(--oz-text-body-sm);
- transition: border-color var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast);
-}
-select:hover, .num-input:hover { border-color: var(--oz-color-border-secondary-hover); }
-.num-input { width: 84px; font-family: var(--oz-font-mono); }
-.check {
- display: inline-flex; align-items: center; gap: var(--oz-space-2);
- color: var(--oz-color-content-secondary); font-size: var(--oz-text-body-sm);
-}
-.check code { font-family: var(--oz-font-mono); font-size: var(--oz-text-body-xs); }
-input[type="checkbox"] { accent-color: var(--oz-color-fill-brand); width: 16px; height: 16px; }
-
-input[type="range"] {
- -webkit-appearance: none; appearance: none;
- background: none; height: 20px; width: 150px; margin: 0;
-}
-input[type="range"]::-webkit-slider-runnable-track {
- height: 3px; border-radius: var(--app-radius-pill); background: var(--oz-color-fill-tertiary);
-}
-input[type="range"]::-moz-range-track {
- height: 3px; border-radius: var(--app-radius-pill); background: var(--oz-color-fill-tertiary);
-}
-/* Neutral thumb: the accent means "this is the answer", and a settings control
- is not an answer. */
-input[type="range"]::-webkit-slider-thumb {
- -webkit-appearance: none; appearance: none;
- width: 14px; height: 14px; border-radius: var(--app-radius-pill);
- background: var(--oz-color-content-primary); margin-top: -5.5px;
- transition: transform var(--oz-spring-spatial-fast-ms) var(--oz-spring-spatial-fast);
-}
-input[type="range"]::-moz-range-thumb {
- width: 14px; height: 14px; border: none;
- border-radius: var(--app-radius-pill); background: var(--oz-color-content-primary);
-}
-input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.15); }
-
-/* =========================== 1. HERO WORKSPACE =========================== */
-/* One row, explicitly 1fr, so the row has a definite height for its child to
- resolve against. Without it the workspace was only min-height-tall and the
- app's own 1fr row had no definite space to claim, so slack leaked into the
- auto rows instead - which is what made the results bar 130px tall and left a
- dead band under the panes. */
-/* A definite height, not a minimum. `height: 100%` on a child cannot resolve
- against a parent that only has `min-height`, so the app's 1fr body row had
- no definite space to claim: slack leaked into the auto rows (a 130px results
- bar) and then into a dead band under the panes. This is an app shell, so a
- fixed viewport height is the honest description - the panes inside scroll. */
-.workspace {
- height: calc(100vh - 56px);
- display: grid;
- /* Both axes wrapped in minmax(0, …): an implicit grid column is max-content,
- so without this the widest descendant sets the shell's width and the page
- scrolls sideways. This is the failure mode the system's layout rules name. */
- grid-template-rows: minmax(0, 1fr);
- grid-template-columns: minmax(0, 1fr);
-}
-.empty { overflow-y: auto; }
-
-.empty {
- display: grid; place-content: center; justify-items: center;
- padding: var(--oz-space-12) var(--oz-space-6);
-}
-/* The dashed edge is the drop affordance - it is what says "this area takes a
- file", so it is a legal border. */
-.empty .drop-target {
- border: var(--oz-stroke-3) dashed var(--oz-color-border-primary);
- border-radius: var(--app-radius-lg);
- padding: var(--oz-space-13) var(--oz-space-14);
- display: grid; gap: var(--oz-space-6); justify-items: center; text-align: center;
- max-width: 780px; width: 100%;
- background: var(--oz-color-surface-primary);
- transition-property: border-color, background-color, transform;
- transition-duration: var(--oz-spring-spatial-default-ms);
- transition-timing-function: var(--oz-spring-spatial-default);
-}
-.empty .drop-target:hover { border-color: var(--oz-color-border-secondary); }
-.empty .drop-target.armed {
- border-color: var(--oz-color-border-brand);
- background: var(--oz-color-fill-brand-secondary);
- transform: scale(calc(1 + 0.008 * var(--oz-motion-spatial-scale)));
-}
-.empty h1 {
- font-family: var(--oz-font-display);
- font-size: var(--oz-text-display-md);
- line-height: var(--oz-leading-display-md);
- letter-spacing: var(--oz-tracking-display-md);
- /* Semibold, not the display step's own 800: this app caps at 600 everywhere.
- See the weight ceiling note at the top of this file. */
- font-weight: var(--oz-weight-semibold);
- margin: 0;
-}
-.empty p {
- color: var(--oz-color-content-secondary); margin: 0;
- max-width: 54ch; font-size: var(--oz-text-body-md); line-height: var(--oz-leading-body-md);
-}
-.empty .micro-hint { color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-sm); }
-
-/* the promise, animated, before a single file is chosen */
-.size-demo {
- display: flex; align-items: center; gap: var(--oz-space-4);
- padding: var(--oz-space-3) var(--oz-space-5);
- border-radius: var(--app-radius-pill);
- background: var(--oz-color-surface-secondary);
- font-size: var(--oz-text-body-md);
-}
-.sd-from { color: var(--oz-color-content-tertiary); text-decoration: line-through; }
-.sd-arrow { color: var(--oz-color-content-tertiary); }
-.sd-to { color: var(--oz-color-content-primary); font-weight: var(--oz-weight-semibold); }
-.sd-pct { color: var(--oz-color-content-success); font-size: var(--oz-text-body-sm); }
-
-.empty-actions { display: flex; gap: var(--oz-space-4); flex-wrap: wrap; justify-content: center; }
-
-.pills {
- display: flex; flex-wrap: wrap; gap: var(--oz-space-2);
- justify-content: center; padding: 0; margin: 0; list-style: none;
-}
-.pills li {
- font-family: var(--oz-font-mono);
- font-size: var(--oz-text-label-xs); letter-spacing: var(--oz-tracking-label-xs);
- color: var(--oz-color-content-tertiary);
- background: var(--oz-color-surface-secondary);
- border-radius: var(--app-radius-pill); padding: var(--oz-space-1) var(--oz-space-4);
-}
-.privacy-line {
- display: flex; align-items: center; gap: var(--oz-space-3);
- color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-sm);
- padding-top: var(--oz-space-5);
-}
-.privacy-line svg { flex: none; color: var(--oz-color-content-success); }
-
-/* ------------------------------- app shell -------------------------------- */
-/* Flex, not grid rows. `.advanced` is toggled with `hidden`, so it generates no
- box, and grid auto-placement shifted every later child up a track: the body
- fell into an auto row and the results bar inherited the 1fr, leaving an empty
- 185px of track beneath it. Flex simply ignores absent children. */
-/* ======================= THE SHELL: three views ==========================
- One thing on screen at a time, decided by what the person is doing. The
- shell is a flex column so the active view takes all the height that is not
- the progress hairline, and `position: relative` so the panel and the
- finished-bar can be positioned against it rather than against the page.
- ======================================================================== */
-.app {
- display: flex; flex-direction: column;
- height: 100%; min-height: 0; min-width: 0;
- position: relative; overflow: hidden;
-}
-.app > .batch { flex: none; }
-/* Every view fills what is left. `min-height: 0` on a flex child is what lets
- an inner scroller actually scroll instead of pushing the column taller. */
-.app > .view { flex: 1 1 auto; min-height: 0; min-width: 0; display: grid; }
-
-/* -------------------------- view: work happening -----------------------
- Its own screen. The original at size, one sentence, one bar, one estimate.
- Centred in a single column because there is exactly one thing to look at
- and nothing to compare it to yet. */
-.view-working {
- grid-template-rows: minmax(0, 1fr) auto;
- place-items: center; gap: var(--oz-space-6);
- padding: var(--oz-space-6);
- background: var(--oz-color-background);
-}
-.working-frame {
- max-width: 100%; max-height: 100%; min-height: 0;
- display: grid; place-items: center;
- border-radius: var(--app-radius-md); overflow: hidden;
- box-shadow: var(--oz-elevation-small);
-}
-.working-frame img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; }
-.working-say {
- display: grid; justify-items: center; gap: var(--oz-space-3);
- width: min(56ch, 100%); text-align: center;
- padding-bottom: var(--oz-space-6);
-}
-.working-track {
- width: 100%; height: 3px; border-radius: var(--app-radius-pill);
- background: var(--oz-color-fill-tertiary); overflow: hidden;
-}
-.working-track i {
- display: block; height: 100%; width: 100%;
- transform: scaleX(var(--p, 0)); transform-origin: left center;
- background: var(--oz-color-border-tertiary);
- transition: transform var(--oz-spring-effects-default-ms) var(--oz-spring-effects-default);
-}
-.working-eta, .working-now {
- margin: 0; font-size: var(--oz-text-body-xs);
- color: var(--oz-color-content-tertiary);
-}
-
-/* ---------------------------- view: one image --------------------------
- The comparison IS the screen. No padding, no card, no bands: the two bars
- float on top of the picture and everything else is one panel away. */
-.view-single { grid-template-rows: minmax(0, 1fr); }
-.view-single > .stage { border-radius: 0; }
-
-.float-bar {
- position: absolute; left: var(--oz-space-4); right: var(--oz-space-4);
- z-index: 2;
- display: flex; align-items: center; gap: var(--oz-space-3);
- padding: var(--oz-space-2) var(--oz-space-3);
- border-radius: var(--app-radius-md);
- /* Floating over a photograph, so it needs its own ground rather than the
- page's - and a blur so the picture stays legible underneath it. */
- background: var(--oz-color-surface-overlay);
- backdrop-filter: blur(12px);
- box-shadow: var(--oz-elevation-small);
-}
-.float-bar.top { top: var(--oz-space-4); }
-.float-bar.bottom { bottom: var(--oz-space-4); }
-/* They float over the picture, so they cannot push the page wide - they have
- to fold instead. At 375px the mode switch and the zoom cluster together are
- wider than the screen, and the first thing probe_mobile said about this
- layout was that the zoomer ran to 457px on a 375px phone. */
-.float-bar { flex-wrap: wrap; }
-.float-bar > * { min-width: 0; }
-.float-bar .segmented, .float-bar .zoomer { flex-wrap: wrap; }
-@media (max-width: 560px) {
- .float-bar { left: var(--oz-space-2); right: var(--oz-space-2); gap: var(--oz-space-2); }
- /* The keycap hints are a desktop affordance; a phone has no Space bar to
- press, so they are noise taking the width the controls need. */
- .float-bar kbd { display: none; }
- .float-bar .spacer { display: none; }
-}
-.float-bar .insp-title { margin: 0; display: flex; align-items: baseline; min-width: 0; }
-.result-size { font-size: var(--oz-text-body-md); font-weight: var(--oz-weight-semibold); }
-.result-saved { font-size: var(--oz-text-body-sm); color: var(--oz-color-content-success); }
-
-/* ---------------------------- view: the list ---------------------------
- Full width. The rail this replaced was 17% of the screen whether it held
- one row or eighty. */
-.view-list {
- grid-template-rows: auto minmax(0, 1fr) auto;
- background: var(--oz-color-surface-primary);
-}
-.list-head {
- display: flex; align-items: center; gap: var(--oz-space-4); flex-wrap: wrap;
- padding: var(--oz-space-4) var(--oz-space-6);
- border-bottom: var(--oz-stroke-1) solid var(--oz-color-border-secondary);
-}
-
-/* ------------------------------- the panel -----------------------------
- One direction for everything deeper. Slides from the right, over the view
- rather than beside it, so no view has to reserve room for it and the
- comparison keeps the whole screen when it is shut.
-
- Translated, never widthed: animating width would relayout every frame, and
- verify_tokens.mjs refuses a transition on a layout property outright. */
-.panel {
- position: absolute; top: 0; right: 0; bottom: 0; z-index: 3;
- width: min(420px, 100%);
- display: grid; grid-template-rows: auto minmax(0, 1fr);
- background: var(--oz-color-surface-primary);
- box-shadow: var(--oz-elevation-large);
- transform: translateX(100%);
- transition: transform var(--oz-spring-spatial-default-ms) var(--oz-spring-spatial-default);
-}
-.panel.on { transform: translateX(0); }
-.panel-head {
- display: flex; align-items: center; gap: var(--oz-space-3);
- padding: var(--oz-space-4) var(--oz-space-5);
- border-bottom: var(--oz-stroke-1) solid var(--oz-color-border-secondary);
-}
-.panel-body { overflow-y: auto; min-height: 0; padding: var(--oz-space-5); }
-.panel-sec { padding-bottom: var(--oz-space-6); }
-.panel-sec + .panel-sec {
- padding-top: var(--oz-space-6);
- border-top: var(--oz-stroke-1) solid var(--oz-color-border-secondary);
-}
-.panel-sec h4 { margin: 0 0 var(--oz-space-4); }
-.panel-sec .field { margin-bottom: var(--oz-space-4); }
-
-/* --------------------------- the end of the task ----------------------- */
-.done-bar {
- position: absolute; left: 50%; bottom: var(--oz-space-7); z-index: 4;
- transform: translateX(-50%);
- display: flex; align-items: center; gap: var(--oz-space-4);
- padding: var(--oz-space-3) var(--oz-space-5);
- border-radius: var(--app-radius-pill);
- background: var(--oz-color-surface-overlay);
- backdrop-filter: blur(12px);
- box-shadow: var(--oz-elevation-small);
- font-size: var(--oz-text-body-sm);
-}
-
-.bar {
- display: flex; align-items: center; gap: var(--oz-space-4); flex-wrap: wrap;
- padding: var(--oz-space-3) var(--oz-space-5); min-height: 52px;
- background: var(--oz-color-surface-primary);
-}
-/* min-width:0 on both is what lets a long option label shrink instead of
- pushing the control off the side of a phone - the Format and Quality
- options are sentences, and at 390px they overhung the viewport by 40px. */
-.field { display: flex; align-items: center; gap: var(--oz-space-3); min-width: 0; }
-.field > label {
- color: var(--oz-color-content-secondary); white-space: nowrap; font-size: var(--oz-text-body-sm);
-}
-.field > select { min-width: 0; max-width: 100%; }
-.spacer { margin-left: auto; }
-
-/* progressive disclosure: the numbers most people never need */
-.advanced {
- display: flex; align-items: center; gap: var(--oz-space-5) var(--oz-space-7); flex-wrap: wrap;
- padding: var(--oz-space-4) var(--oz-space-5);
- background: var(--oz-color-surface-secondary);
- animation: oz-enter-rise var(--oz-spring-spatial-fast-ms) var(--oz-spring-spatial-fast) both;
-}
-.adv-note {
- color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-xs);
- margin: 0; min-width: 22ch;
-}
-/* ---- the plan sentence ---------------------------------------------------
- The one place this interface spends its boldness. Everything about it serves
- a single idea: the app says what it is about to do, in a sentence, and every
- word it could have chosen differently is the control that changes it.
-
- That forces an unusual balance on the controls. They have to read as prose
- first and as inputs second - present enough to invite a click, quiet enough
- that the sentence still scans as a sentence rather than as a row of widgets
- with words wedged between them. Hence: no select chrome, weight and colour
- carrying the "this is editable" signal, and a brand underline that only
- fully saturates on hover. */
-.plan {
- margin: 0;
- padding: var(--oz-space-5) var(--oz-space-6);
- background: var(--oz-color-surface-primary-variant);
- border: var(--oz-stroke-2) solid var(--oz-color-border-primary);
- border-radius: var(--oz-radius-7);
- font-family: var(--oz-font-body);
- font-size: var(--oz-text-body-md);
- /* Loose on purpose. The picks are their own inline boxes and a normal
- leading makes them collide against the line above once this wraps, which
- on a phone it always does. */
- line-height: 2.15;
- color: var(--oz-color-content-tertiary);
-}
-/* The connective words never break mid-phrase; the sentence wraps between
- slots instead, which keeps each clause readable at any width. */
-.plan-txt { white-space: nowrap; }
-
-/* A