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 - -
- Compress an image -
-
- -
-

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

- -{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.

- -

Try it on your own - image

-
-""" - - -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 as permission to stop theming its popup, and `transparent` got it - drawn as a white sheet over a dark page. */ - background-color: var(--oz-color-surface-primary-variant); - border: 0; - border-bottom: var(--oz-stroke-3) solid var(--oz-color-border-brand-secondary); - padding: 0 var(--oz-space-6) var(--oz-space-1) var(--oz-space-1); - max-width: 100%; - cursor: pointer; - transition: border-color var(--oz-duration-fast) var(--oz-ease-standard), - background-color var(--oz-duration-fast) var(--oz-ease-standard); -} -/* The open list is a native widget drawn from the control's colours and from - color-scheme, not from anything scoped to this component. Stating both - explicitly here is what keeps it readable in either theme whatever the - closed-state styling above does. */ -.plan-pick > select option { - color: var(--oz-color-content-primary); - background-color: var(--oz-color-surface-elevated); - font-weight: var(--oz-weight-regular); -} -.plan-num { - appearance: none; -webkit-appearance: none; - background: transparent; border-top: 0; border-left: 0; border-right: 0; - font-family: var(--oz-font-mono); - /* Same size as the words it sits between, deliberately. A step down for the - mono face left this box 4px shorter than its neighbours and 2px off their - baseline, which on one line of prose reads as a wobble rather than as a - smaller number. Mono runs large at a given size; here that is welcome, - because the figure is the part of the clause carrying the value. */ - font-size: inherit; - text-align: center; - cursor: text; - width: 6ch; -} -#plan-cap { width: 7ch; } -.plan-pick > select:hover, -.plan-num:hover { - background-color: var(--oz-color-fill-brand-secondary); - border-bottom-color: var(--oz-color-border-brand); -} -.plan-pick > select:focus-visible, -.plan-num:focus-visible { - outline: var(--oz-focus-ring-width) solid var(--oz-color-border-focus); - outline-offset: var(--oz-focus-ring-offset); - border-bottom-color: var(--oz-color-border-brand); -} -/* No spinner. The step is 64px and nobody nudges a pixel count one rung at a - time; typing is the interaction, and the arrows only crowd the mono digits. */ -.plan-num { -moz-appearance: textfield; } -.plan-num::-webkit-outer-spin-button, -.plan-num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } - -/* The one failure state either search has: a size cap that could not be met - without dropping below the floor. Warning, not critical - nothing broke, the - engine did the honest thing and handed back the smallest file still worth - looking at. But the result is over a limit someone set on purpose, so it has - to be visible before the download rather than after the upload. */ -.warn.missed { - background: var(--oz-color-surface-warning); - border-left: var(--oz-stroke-6) solid var(--oz-color-border-warning); - border-radius: var(--oz-radius-3); - padding: var(--oz-space-4) var(--oz-space-5); -} - -/* Standing on its own under the hero rather than inside the drawer's column, - so it has to supply the width and the air the drawer was giving it. Set a - step larger here: on the landing screen this is the only thing on the page - making a promise, and it is competing with a 64px headline. */ -#plan-home-empty { - width: min(100%, 760px); - margin: var(--oz-space-12) auto 0; - text-align: left; -} -#plan-home-empty > #bar-controls { padding: 0; border: 0; } -#plan-home-empty .plan { font-size: var(--oz-text-body-lg); } - -/* What the chosen floor means, in the metric's own published words. Sits - under the sentence rather than beside a slider, because it explains the - whole claim and not one control. */ -.plan-why { - margin: var(--oz-space-3) 0 0; - padding-left: var(--oz-space-6); - color: var(--oz-color-content-tertiary); - font-size: var(--oz-text-body-xs); -} - -/* ======================== 2. PROCESSING DASHBOARD ======================== */ -.batch { - height: 2px; background: var(--oz-color-surface-tertiary); - opacity: 0; transition: opacity var(--oz-spring-effects-default-ms) var(--oz-spring-effects-default); -} -.batch.on { opacity: 1; } -/* Neutral, not brand. A full-width orange line under the toolbar reads as an - alarm; work in progress is not an error, and the accent is reserved. */ -/* scaleX, not width. Animating width makes the browser redo layout on every - frame of every bar; transform is composited and cannot reflow anything. The - fraction arrives as a unitless --p from renderBatchProgress. */ -.batch 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-slow-ms) var(--oz-spring-effects-slow); -} - -.body { - display: grid; grid-template-columns: 324px minmax(0, 1fr); - gap: var(--oz-space-3); padding: var(--oz-space-3); - min-height: 460px; -} - -/* Each functional group is a card, so the panes read as separate tools rather - than one continuous sheet. Depth is shadow in light and a lighter surface in - dark, per the system - never a border. */ -/* Elevation is shadow in light and surface lightness in dark, exactly as the - system specifies. That matters here: light mode's page-to-card step is only - #FFFFFF → #F7F5F4, far too tight to read a card edge on its own, and a - border would be a separation border. The shadow does the work in light; in - dark the surface step already does it and the shadow is barely visible. */ -.card { - background: var(--oz-color-surface-primary); - border-radius: var(--app-radius-lg); - overflow: hidden; - box-shadow: var(--oz-elevation-x-small); -} - -.queue { - display: grid; grid-template-rows: auto minmax(0,1fr) auto; - min-height: 0; -} -/* Batch numbers and batch actions in one bar, directly above the list. */ -.queue-head { - display: flex; align-items: center; gap: var(--oz-space-3); - padding: var(--oz-space-3) var(--oz-space-4); - background: var(--oz-color-surface-secondary); -} -.qh-stats { display: flex; align-items: baseline; gap: var(--oz-space-2); min-width: 0; } -.qh-count { font-size: var(--oz-text-body-md); font-weight: var(--oz-weight-semibold); } -.qh-label { color: var(--oz-color-content-tertiary); } -.live-stat { - font-size: var(--oz-text-body-xs); color: var(--oz-color-content-success); - margin-left: var(--oz-space-2); -} -.queue-list { overflow-y: auto; padding: 0 var(--oz-space-3) var(--oz-space-3); min-height: 0; } -.queue-foot { - padding: var(--oz-space-3) var(--oz-space-5); - color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-xs); -} -.queue-empty { - padding: var(--oz-space-9) var(--oz-space-5); text-align: center; - color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-sm); -} - -/* The row got the width the sidebar used to take, so it can carry what it - did rather than only that it is finished: thumb, name and sizes, the - saving drawn to the batch's scale, the format that won, and its state. */ -.row { - display: grid; - grid-template-columns: 46px minmax(0, 1fr) minmax(90px, 22%) 7rem auto; - gap: var(--oz-space-4); align-items: center; - width: 100%; text-align: left; - padding: var(--oz-space-3); border-radius: var(--app-radius-md); - background: transparent; - transition: background-color var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast); -} -.saving { display: flex; align-items: center; gap: var(--oz-space-3); min-width: 0; } -.saving-bar { - flex: 1 1 auto; height: 6px; min-width: 0; - border-radius: var(--app-radius-pill); - background: var(--oz-color-fill-tertiary); overflow: hidden; -} -/* scaleX, never width: a transitioned width relayouts the whole list on every - frame of every bar, and verify_tokens refuses it outright. */ -.saving-bar i { - display: block; height: 100%; width: 100%; - transform: scaleX(var(--p, 0)); transform-origin: left center; - background: var(--oz-color-content-success); - transition: transform var(--oz-spring-effects-default-ms) var(--oz-spring-effects-default); -} -.saving-pct { - font-size: var(--oz-text-body-xs); color: var(--oz-color-content-success); - white-space: nowrap; -} -.won { - font-size: var(--oz-text-body-xs); color: var(--oz-color-content-tertiary); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -/* Below a tablet the two measured columns have nowhere to go; the sizes are - still in .meta, so nothing is lost by dropping them. */ -@media (max-width: 720px) { - .row { grid-template-columns: 46px minmax(0, 1fr) auto; } - .row .saving, .row .won { display: none; } -} -.row + .row { margin-top: var(--app-gap-tight); } -.row:hover { background: var(--oz-color-fill-secondary-hover); } -/* Selected is a state, so it may carry a stroke - drawn as an inset rail so it - costs no layout and cannot shift the row. The fill stays neutral: brand at - 15% over a white page renders pink, which reads destructive rather than - selected, and the accent has two jobs already. (That rules out - `--oz-color-fill-selected` here, which is exactly that 15% brand.) - - The rail is 2px, not 4px. At 4px it was the loudest thing in the queue and - competed with the primary action for attention; the surface step already - says "this row", and the rail only has to confirm it. */ -.row[aria-selected="true"] { - background: var(--oz-color-fill-secondary-active); - box-shadow: inset var(--oz-stroke-4) 0 0 var(--oz-color-border-selected); -} -.row .thumb { - width: 46px; height: 46px; border-radius: var(--app-radius-sm); - background-color: var(--oz-color-surface-tertiary); - background-size: cover; background-position: center; -} -/* One shape at every stage: name, weights, phase. The rows keep the same three - lines from queued to saved, so the list never reflows as items finish. */ -.row .cell { display: flex; flex-direction: column; min-width: 0; gap: 1px; } -.row .name { - display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-size: var(--oz-text-body-sm); -} -.row .meta { - display: block; font-family: var(--oz-font-mono); font-size: var(--oz-text-body-xs); - color: var(--oz-color-content-secondary); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -.row .phase { - display: block; font-size: var(--oz-text-label-xs); - color: var(--oz-color-content-tertiary); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - min-height: 1.2em; -} -.row .phase:empty { min-height: 0; } -.row .meta .save { color: var(--oz-color-content-success); } -.row .meta .err { color: var(--oz-color-content-critical); } -.row .tail { display: flex; align-items: center; gap: var(--oz-space-2); } -.dot { - width: 6px; height: 6px; border-radius: var(--app-radius-pill); - background: var(--oz-color-content-tertiary); flex: none; -} -.dot.done, .dot.saved { background: var(--oz-color-fill-success); } -.dot.failed { background: var(--oz-color-fill-critical); } -.dot.cancelled { background: var(--oz-color-content-tertiary); } -.dot.saved { box-shadow: 0 0 0 3px var(--oz-color-fill-success-secondary); } -/* Scale on arrival: spatial, so it overshoots, and it collapses under reduced - motion because the dot's colour already carries the state. */ -.dot.done, .dot.saved { - animation: dot-pop var(--oz-spring-spatial-default-ms) var(--oz-spring-spatial-default); -} -@keyframes dot-pop { - from { transform: scale(calc(1 - 0.7 * var(--oz-motion-spatial-scale))); } - to { transform: none; } -} - -.row .track { - display: none; height: 2px; border-radius: var(--app-radius-pill); - background: var(--oz-color-fill-tertiary); overflow: hidden; margin-top: var(--oz-space-1); -} -.row.working .track { display: block; } -/* Also neutral: three orange hairlines stacked under three filenames looked - like three failures. */ -.track i { - display: block; height: 100%; width: 100%; - transform: scaleX(var(--p, 0)); transform-origin: left center; - background: var(--oz-color-border-tertiary); border-radius: var(--app-radius-pill); - transition: transform var(--oz-spring-effects-default-ms) var(--oz-spring-effects-default); -} -.row.enter { animation: oz-enter-rise var(--oz-spring-spatial-default-ms) var(--oz-spring-spatial-default) both; } - -/* ============================ 3. PROOF PANEL ============================= */ -.inspector { - display: grid; grid-template-rows: minmax(0, 1fr); min-height: 0; - background: var(--oz-color-surface-primary); - border-radius: var(--app-radius-lg); overflow: hidden; - box-shadow: var(--oz-elevation-x-small); -} - -/* Canvas controls docked above the image instead of floating over it. */ -.canvas-bar { - display: flex; align-items: center; gap: var(--oz-space-3); - padding: var(--oz-space-2) var(--oz-space-4); - background: var(--oz-color-surface-secondary); -} -.zoomer { - display: inline-flex; align-items: center; gap: var(--app-gap-tight); - background: var(--oz-color-fill-tertiary); - border-radius: var(--app-radius-md); padding: var(--app-gap-tight); -} -.zoomer button { - min-width: var(--app-control-h-sm); min-height: var(--app-control-h-sm); padding: 0 var(--oz-space-3); - border-radius: var(--app-radius-sm); - color: var(--oz-color-content-secondary); font-size: var(--oz-text-body-sm); - transition: background-color var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast); -} -.zoomer button:hover { background: var(--oz-color-fill-tertiary-hover); color: var(--oz-color-content-primary); } -.zoomer .z { - font-size: var(--oz-text-body-xs); color: var(--oz-color-content-tertiary); - padding: 0 var(--oz-space-2); min-width: 3.4em; text-align: center; -} -.insp-placeholder { display: grid; place-content: center; color: var(--oz-color-content-tertiary); } -/* Flex for the same reason: the detail panel is collapsible, so a row template - would shift under it. The stage is the part that grows. */ -#inspector-body { - display: flex; flex-direction: column; - min-height: 0; min-width: 0; height: 100%; -} -#inspector-body > .insp-head, #inspector-body > .canvas-bar, -#inspector-body > .readout { flex: none; } -#inspector-body > .stage { flex: 1 1 auto; } -/* The evidence panel is capped as a share of the pane so the image always - keeps the majority of the height. Tightened from 38% when the readout band - arrived between them: the sentence and the chips are what the result state - is for, and the numbers below were the part that could afford the room. */ -#inspector-body > .details { flex: none; max-height: 30%; } -.insp-head { - display: flex; align-items: baseline; gap: var(--oz-space-4); flex-wrap: wrap; - padding: var(--oz-space-4) var(--oz-space-6); -} -.insp-head h2 { - font-family: var(--oz-font-heading); - font-size: var(--oz-text-heading-xs); - line-height: var(--oz-leading-heading-xs); - letter-spacing: var(--oz-tracking-heading-xs); - font-weight: var(--oz-default-weight-heading); - margin: 0; overflow-wrap: anywhere; -} -/* The filename is editable in place. It should read as the heading it is until - someone reaches for it - most people never will - so the field carries no - chrome until hover or focus. The extension rides beside it, unowned by the - field, because the encode decides it. */ -.insp-title { display: flex; align-items: baseline; min-width: 0; max-width: 44ch; } -/* Width comes from the `size` attribute, set in JS from the name's own length. - A percentage width made the field span the whole title and parked the - extension a hand's width from the name it belongs to. */ -.insp-title #insp-name { - font: inherit; color: inherit; width: auto; max-width: 100%; min-width: 4ch; - background: transparent; border: var(--oz-stroke-1) solid transparent; - border-radius: var(--app-radius-sm); - padding: var(--oz-space-1) var(--oz-space-2); margin-left: calc(var(--oz-space-2) * -1); - text-overflow: ellipsis; -} -.insp-title #insp-name:hover { border-color: var(--oz-color-border-primary); } -.insp-title #insp-name:focus { - outline: none; background: var(--oz-color-fill-tertiary); - border-color: var(--oz-color-border-selected); -} -.insp-ext { flex: none; color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-sm); } -.insp-head .dims { color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-xs); } - -/* A plain ground, not a checkerboard. The checker belongs to the image's own - box — spread across the whole pane it made every opaque photograph look like - it was floating in a void, and it competed with the picture for attention. */ -.stage { - position: relative; overflow: hidden; min-height: 260px; - background: var(--oz-color-background); - display: grid; place-items: center; -} -/* Centred by transform, not by the grid. CSS alignment silently switches from - `center` to `start` once an item is bigger than its container - the "safe" - behaviour, meant to protect content from being scrolled off - so the moment - you zoomed past the stage the frame snapped to the top-left and the rest of - the picture hung off the bottom. That is what "scrolling takes me to the top - and I have to drag a long way back" was. A transform has no such rule, so - the frame stays centred at every scale and panning starts from the middle. */ -.viewport { - position: absolute; left: 50%; top: 50%; - line-height: 0; touch-action: none; - background-color: var(--checker-a); - background-image: - linear-gradient(45deg, var(--checker-b) 25%, transparent 25%, transparent 75%, var(--checker-b) 75%), - linear-gradient(45deg, var(--checker-b) 25%, transparent 25%, transparent 75%, var(--checker-b) 75%); - background-size: 18px 18px; - background-position: 0 0, 9px 9px; - box-shadow: var(--oz-elevation-small); -} -.viewport img, .viewport canvas { - display: block; width: 100%; height: 100%; object-fit: contain; - user-select: none; -webkit-user-drag: none; -} -/* An with nothing to show is not blank: with no src it lays out its alt - text, and with a src that will not decode it paints the browser's broken - glyph next to it. Both landed in the corner of the stage - a torn-page icon - and the words "Original image" over the artwork. An image element that has - no image is removed from the layout instead, and the stage says what - happened in its own words (see .stage-none). */ -.viewport img:not([src]), .viewport img.dead { display: none; } -.viewport .after { position: absolute; inset: 0; overflow: hidden; clip-path: inset(0 0 0 var(--split)); } -.viewport .after img { position: absolute; inset: 0; width: 100%; height: 100%; } -/* The one orchestrated moment in the app: when a result lands, the compressed - half sweeps in from the right so you see it arrive over the original. Spatial, - so reduced motion drops the travel and leaves the result in place. */ -.viewport .after.wipe { animation: wipe-in var(--oz-spring-spatial-slow-ms) var(--oz-spring-spatial-slow) both; } -@keyframes wipe-in { - from { clip-path: inset(0 0 0 calc(100% - (100% - var(--split)) * var(--oz-motion-spatial-scale))); } - to { clip-path: inset(0 0 0 var(--split)); } -} -.viewport.solo .after { clip-path: none; } -.viewport.peek .after { clip-path: inset(0 0 0 100%) !important; } -.viewport.peek .divider { opacity: 0; } -#img-diff { position: absolute; inset: 0; width: 100%; height: 100%; } - -/* The divider is a control and carries the `dark` class, so `border-inverse` - resolves to the dark mode's light value in both themes - it has to read on a - photograph, not on the page. job: affordance */ -.divider { - position: absolute; top: 0; bottom: 0; left: var(--split); - width: var(--oz-stroke-2); background: var(--oz-color-border-inverse); - box-shadow: var(--oz-elevation-x-small); pointer-events: none; -} -/* The handle is a caliper thumb: tick-marked, so it reads as an instrument for - measuring rather than a generic drag dot. */ -.divider::after { - content: ""; position: absolute; top: 50%; left: 50%; - width: 30px; height: 30px; margin: -15px 0 0 -15px; - border-radius: var(--app-radius-pill); - background: var(--oz-color-border-inverse); - box-shadow: var(--oz-elevation-small); - transition: transform var(--oz-spring-spatial-fast-ms) var(--oz-spring-spatial-fast); -} -.divider::before { - content: ""; position: absolute; top: 50%; left: 50%; - width: 14px; height: 10px; margin: -5px 0 0 -7px; z-index: 1; - background-image: repeating-linear-gradient( - to right, - var(--oz-color-content-inverse-primary) 0 1px, - transparent 1px 4px); - opacity: .55; pointer-events: none; - transition: transform var(--oz-spring-spatial-fast-ms) var(--oz-spring-spatial-fast); -} -.split-input:hover ~ .divider::before { transform: scale(1.15); } -.split-input { - position: absolute; inset: 0; width: 100%; height: 100%; - opacity: 0; cursor: col-resize; margin: 0; -} -.split-input:hover + .divider::after { transform: scale(1.15); } -.split-input:focus-visible + .divider { background: var(--oz-color-border-focus); } - -/* The scrim behind these labels is authored dark in BOTH modes, so the label - has to stay light in both. Rather than a mode-specific override — which the - system forbids, because the two modes then drift — each tag carries the - `dark` class in the markup and is a scoped dark island: `content-primary` - resolves to the dark value inside it whatever the page is doing. */ -.tag { - position: absolute; top: var(--oz-space-4); - padding: var(--oz-space-1) var(--oz-space-3); border-radius: var(--app-radius-pill); - background: var(--oz-overlay-dimness); - color: var(--oz-color-content-primary); - font-family: var(--oz-font-label); - font-size: var(--oz-text-label-xs); font-weight: var(--oz-weight-semibold); - letter-spacing: var(--oz-tracking-label-xs); text-transform: uppercase; - backdrop-filter: blur(var(--oz-overlay-blur)); - pointer-events: none; - transition: opacity var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast); -} -/* Riding the caliper: each weight sits against the line it belongs to, so the - numbers travel with the comparison instead of being parked in the corners. */ -.divider .tag { - top: 50%; white-space: nowrap; - transform: translateY(-50%); -} -.divider .tag.l { right: var(--oz-space-6); left: auto; } -.divider .tag.r { left: var(--oz-space-6); right: auto; } -/* Used in After and Diff, where there is no divider to hang a label on. */ -.tag.corner { top: var(--oz-space-4); right: var(--oz-space-4); } - -.hint { - position: absolute; left: 50%; bottom: var(--oz-space-6); - transform: translate(-50%, calc(8px * var(--oz-motion-spatial-scale))); - padding: var(--oz-space-3) var(--oz-space-5); border-radius: var(--app-radius-pill); - background: var(--oz-color-surface-overlay); - box-shadow: var(--oz-elevation-medium); - font-size: var(--oz-text-body-sm); color: var(--oz-color-content-secondary); - opacity: 0; pointer-events: none; - transition: opacity var(--oz-spring-effects-default-ms) var(--oz-spring-effects-default), - transform var(--oz-spring-spatial-default-ms) var(--oz-spring-spatial-default); -} -.hint.on { opacity: 1; transform: translate(-50%, 0); } -/* Sits in the same centred cell as the viewport, so it reads as the preview's - replacement rather than a caption under it. job: status */ -.stage-none { - max-width: 34ch; text-align: center; - font-size: var(--oz-text-body-sm); color: var(--oz-color-content-tertiary); - padding: var(--oz-space-6); -} -kbd { - font-family: var(--oz-font-mono); font-size: var(--oz-text-label-xs); - background: var(--oz-color-fill-tertiary); - border-radius: var(--app-radius-sm); padding: 1px var(--oz-space-2); -} - -.details { - background: var(--oz-color-surface-primary); - display: grid; grid-template-columns: 1fr 1fr; - max-height: 300px; overflow-y: auto; -} -.pane { padding: var(--oz-space-5) var(--oz-space-6); } -.pane h3 { margin: 0 0 var(--oz-space-4); } - -/* The three numbers that decide whether you keep the file, at a size you can - read across a desk. Everything else is one click away in the drawer below. */ -.stats-lead { - display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--oz-space-5); -} -.stat-big .k { color: var(--oz-color-content-tertiary); } -.stat-big .v { - font-family: var(--oz-font-mono); - font-size: var(--oz-text-heading-sm); line-height: var(--oz-leading-heading-sm); - margin-top: var(--oz-space-1); transform-origin: left center; - overflow-wrap: anywhere; -} -.stat-big .v small { - display: block; font-family: var(--oz-font-body); - font-size: var(--oz-text-body-xs); color: var(--oz-color-content-tertiary); -} -.stat-big.accent .v { color: var(--oz-color-content-success); } -/* The format is a name, not a measurement - it does not earn headline size - next to two numbers that do. */ -.stat-big.name .v { font-size: var(--oz-text-body-md); padding-top: var(--oz-space-1); } -/* A pending value is a shape, not a dash. Three em-dashes read as "nothing - here"; a skeleton reads as "not yet". `.oz-ambient` is the system's opt-in - marker for a decorative loop, so reduced motion switches the pulse off. */ -.skel { - display: inline-block; width: 3.6em; height: .78em; - border-radius: var(--app-radius-sm); - background: var(--oz-color-fill-tertiary); - vertical-align: baseline; -} -.skel.w-sm { width: 2.4em; } -.stat-big .v .skel { height: .68em; } - -.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: var(--oz-space-4) var(--oz-space-6); } -.stat .k { - color: var(--oz-color-content-tertiary); font-size: var(--oz-text-label-xs); - line-height: var(--oz-leading-label-sm); -} -.stat .v { - font-family: var(--oz-font-mono); font-size: var(--oz-text-body-sm); - line-height: var(--oz-leading-body-sm); margin-top: 2px; -} -.stat .v small { font-size: var(--oz-text-body-xs); color: var(--oz-color-content-tertiary); } -#s-saved.pop { animation: stat-pop var(--oz-spring-spatial-default-ms) var(--oz-spring-spatial-default); } - -/* Advanced info: present, one click away, never in the way. */ -.advanced-info { margin-top: var(--oz-space-5); } -.advanced-info summary { - cursor: pointer; list-style: none; - color: var(--oz-color-content-secondary); font-size: var(--oz-text-body-sm); -} -.advanced-info summary::-webkit-details-marker { display: none; } -.advanced-info summary::before { content: "▸ "; color: var(--oz-color-content-tertiary); } -.advanced-info[open] summary::before { content: "▾ "; } -.advanced-info .stats { margin-top: var(--oz-space-4); } -.metric-note { - color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-xs); - margin: var(--oz-space-4) 0 0; max-width: 60ch; -} -/* Where the saving actually came from, and the check on transparent edges. - Both are findings rather than readouts, so they read as sentences and sit - just under the verdict they qualify. The transparency one gets the success - colour because it is the only line on screen that reports a test the image - passed that nobody asked for. */ -.split-note, .alpha-note { - font-size: var(--oz-text-body-xs); line-height: var(--oz-leading-body-xs); - margin: var(--oz-space-3) 0 0; max-width: 60ch; - color: var(--oz-color-content-secondary); -} -.alpha-note { color: var(--oz-color-content-success); } -#keep-size { margin-top: var(--oz-space-3); } -.hint-inline { - font-family: var(--oz-font-body); text-transform: none; letter-spacing: 0; - font-weight: var(--oz-weight-regular); color: var(--oz-color-content-tertiary); - font-size: var(--oz-text-body-xs); margin-left: var(--oz-space-2); -} -@keyframes stat-pop { - 0% { transform: scale(calc(1 - 0.06 * var(--oz-motion-spatial-scale))); } - 100% { transform: none; } -} - -.verdict { - margin: var(--oz-space-4) 0 0; padding-top: var(--oz-space-4); - font-size: var(--oz-text-body-sm); color: var(--oz-color-content-secondary); -} -.verdict b { color: var(--oz-color-content-primary); } -.warn { color: var(--oz-color-content-critical); font-size: var(--oz-text-body-sm); margin-top: var(--oz-space-3); } -.note { color: var(--oz-color-content-secondary); font-size: var(--oz-text-body-sm); margin-top: var(--oz-space-3); } -.note.m0 { margin: 0; } - -/* ========================== 3b. THE READOUT ============================== * - The band between the picture and the numbers: one sentence, and every - encode that was tried as something you can touch. It is `flex: none` so it - never takes height from the stage - the image stays the subject. */ -.readout { - display: grid; gap: var(--oz-space-3); - padding: var(--oz-space-4) var(--oz-space-6); - background: var(--oz-color-surface-primary); -} -/* Body size, not label size. This line is the one thing on the screen that has - to be read by someone who is not looking for it, so it is set at reading - size in the reading face and given the primary content colour - a muted - caption here would be a caption, and this is not one. */ -.narration { - margin: 0; min-height: var(--oz-leading-body-md); - font-size: var(--oz-text-body-md); line-height: var(--oz-leading-body-md); - color: var(--oz-color-content-primary); -} -.narration b { color: var(--oz-color-content-primary); } -/* While the work runs the sentence is a promise, not a result: it steps back a - rung so the picture above it keeps the eye. */ -.narration.working { color: var(--oz-color-content-secondary); } -/* The invitation. A real button that looks like the link it is: underlined, in - the reading face, on the same baseline as the sentence it finishes. */ -.narr-link { - font: inherit; padding: 0; color: var(--oz-color-content-brand); - text-decoration: underline; - text-decoration-color: var(--oz-color-border-brand); - text-underline-offset: 3px; - transition: color var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast); -} -.narr-link:hover { - color: var(--oz-color-content-brand-hover); - text-decoration-color: var(--oz-color-border-brand-hover); -} - -/* Every encode that was tried, smallest first, as chips. Horizontal and - scrolling rather than a stacked table: a row of touchable things next to the - picture reads as a set of choices, where a column of rows under a heading - reads as a report. The meter along the bottom edge carries the size - comparison without competing with the label for the eye. */ -.cands { - display: flex; gap: var(--oz-space-2); align-items: stretch; - overflow-x: auto; overflow-y: hidden; - padding-bottom: var(--oz-space-2); margin-bottom: calc(var(--oz-space-2) * -1); - scrollbar-width: thin; -} -/* In the panel there is no width to scroll sideways into, so they wrap. A - sideways scroller inside a 420px drawer hides its own contents. */ -.panel .cands { flex-wrap: wrap; overflow-x: visible; } -.panel .cand { flex: 1 1 calc(50% - var(--oz-space-2)); } -/* Equal width, always: `flex: 1 0 ` gives every chip the same basis and - the same share of any slack, and forbids shrinking. That is not a tidiness - preference — the meter along the bottom edge is a percentage of the chip, so - on ragged widths two meters of the same length would mean two different - sizes. Equal chips are what make the meter honest. Long labels ellipsis - rather than widening their chip; the full name is in the title. */ -.cand { - position: relative; overflow: hidden; flex: 1 0 10.5rem; max-width: 16rem; - text-align: left; - display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; - gap: var(--oz-space-1) var(--oz-space-3); - padding: var(--oz-space-3) var(--oz-space-4) var(--oz-space-4); - border-radius: var(--app-radius-md); - background: var(--oz-color-fill-tertiary); - color: var(--oz-color-content-primary); - font-size: var(--oz-text-body-sm); cursor: pointer; - transition-property: background-color, box-shadow, transform; - transition-duration: var(--oz-spring-effects-fast-ms); - transition-timing-function: var(--oz-spring-effects-fast); -} -.cand:hover { background: var(--oz-color-fill-tertiary-hover); } -.cand:active { transform: translateY(1px); } -/* Current is a state, so an inset ring is legal and costs no layout. */ -.cand.current { box-shadow: inset 0 0 0 var(--oz-stroke-4) var(--oz-color-border-selected); } -.cand > span { position: relative; } -.cand .f { - font-weight: var(--oz-weight-medium); white-space: nowrap; - overflow: hidden; text-overflow: ellipsis; -} -.cand .mark { - justify-self: end; - font-family: var(--oz-font-label); - font-size: var(--oz-text-label-xs); font-weight: var(--oz-weight-semibold); - letter-spacing: var(--oz-tracking-label-xs); text-transform: uppercase; - color: var(--oz-color-content-tertiary); white-space: nowrap; -} -/* The one brand mark down here. The winning encode is the app's answer, and - this is the same job the badge did in the old leaderboard. */ -.cand.win .mark { color: var(--oz-color-content-brand); } -.cand .b { - font-size: var(--oz-text-body-xs); color: var(--oz-color-content-secondary); - white-space: nowrap; -} -.cand .p { - justify-self: end; font-size: var(--oz-text-body-xs); - color: var(--oz-color-content-success); white-space: nowrap; -} -/* Size, as a rule along the bottom edge. Absolute, so it cannot add height, and - neutral in every chip — the ring says which one is showing and the badge says - which one won, so a coloured meter would be a third voice saying nothing new. */ -.cand .meter { - position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; - transform: scaleX(var(--w, 0)); transform-origin: left center; - background: var(--oz-color-border-tertiary); - transition: transform var(--oz-spring-spatial-slow-ms) var(--oz-spring-spatial-slow); -} -.cand-wait { - font-size: var(--oz-text-body-xs); color: var(--oz-color-content-tertiary); - font-family: var(--oz-font-mono); align-self: center; -} -/* Why the version on screen won, or why the one you picked did not. Sits - directly under the row it explains, in the same register as the narration - - a sentence, not a readout, so it is prose rather than mono. */ -.cand-why { - margin: var(--oz-space-2) 0 0; - font-size: var(--oz-text-body-xs); - color: var(--oz-color-content-tertiary); - line-height: var(--oz-leading-body-xs); -} -/* The answer to clicking "prefer something else?". One pass, on an effects - spring so it cannot overshoot, and it is a response to an action rather than - an ambient loop - so it is not marked `.oz-ambient` and does not need to be - switched off; reduced motion keeps the colour change and drops nothing that - carries meaning. */ -.cands.calling .cand { - animation: chip-call var(--oz-spring-effects-slow-ms) var(--oz-spring-effects-slow) 2; -} -@keyframes chip-call { - 50% { background-color: var(--oz-color-fill-brand-secondary); } -} - -/* The manual overrides. A form, laid out as one. */ -.ov-grid { - display: grid; grid-template-columns: auto minmax(0, 1fr); - gap: var(--oz-space-3) var(--oz-space-4); align-items: center; -} -.ov-grid label { color: var(--oz-color-content-secondary); font-size: var(--oz-text-body-sm); } -.ov-grid .num-input { width: 100%; } -.ov-actions { - display: flex; align-items: center; gap: var(--oz-space-3); - flex-wrap: wrap; margin-top: var(--oz-space-4); -} - -/* ========================= 4. RESULTS SUMMARY ============================ */ -/* Two slots and a fixed height. Left to wrap, this bar grew to 130px and ate - the pane above it. */ -.summary { - display: flex; align-items: center; gap: var(--oz-space-5); flex-wrap: nowrap; - padding: var(--oz-space-3) var(--oz-space-6); height: 64px; - background: var(--oz-color-surface-secondary); - position: sticky; bottom: 0; z-index: var(--oz-layer-sticky); -} -.totals { - display: flex; align-items: baseline; gap: var(--oz-space-4); - flex-wrap: nowrap; min-width: 0; overflow: hidden; -} -.totals > * { white-space: nowrap; } -.totals .big { font-size: var(--oz-text-body-sm); } -.totals .save { color: var(--oz-color-content-success); font-size: var(--oz-text-body-sm); } -/* Bandwidth at scale as a badge with the arithmetic in its tooltip, rather - than a sentence competing with the totals beside it. */ -.badge-bw { - font-family: var(--oz-font-mono); font-size: var(--oz-text-label-xs); - color: var(--oz-color-content-secondary); - background: var(--oz-color-fill-tertiary); - border-radius: var(--app-radius-pill); - padding: var(--oz-space-1) var(--oz-space-3); - cursor: help; white-space: nowrap; -} - -.export { position: relative; } -.menu { - position: absolute; right: 0; bottom: calc(100% + var(--oz-space-2)); - background: var(--oz-color-surface-overlay); - border-radius: var(--app-radius-md); box-shadow: var(--oz-elevation-large); - display: grid; min-width: 190px; padding: var(--oz-space-1); - z-index: var(--oz-layer-popover); - animation: oz-enter-pop var(--oz-spring-spatial-fast-ms) var(--oz-spring-spatial-fast) both; -} -.menu button { - text-align: left; padding: var(--oz-space-3) var(--oz-space-4); - border-radius: var(--app-radius-sm); - font-size: var(--oz-text-body-sm); color: var(--oz-color-content-secondary); - transition: background-color var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast); -} -.menu button:hover { background: var(--oz-color-fill-tertiary-hover); color: var(--oz-color-content-primary); } - -/* ------------------------------- overlays -------------------------------- */ -.veil { - position: fixed; inset: 0; z-index: var(--oz-layer-overlay); - display: grid; place-items: center; - background: var(--oz-overlay-dimness); - backdrop-filter: blur(var(--oz-overlay-blur)); - opacity: 0; pointer-events: none; - transition: opacity var(--oz-spring-effects-default-ms) var(--oz-spring-effects-default); -} -.veil.on { opacity: 1; } -.veil .box { - border: var(--oz-stroke-4) dashed var(--oz-color-border-brand); /* job: affordance */ - border-radius: var(--app-radius-lg); - padding: var(--oz-space-12) var(--oz-space-14); text-align: center; - background: var(--oz-color-surface-overlay); - box-shadow: var(--oz-elevation-large); - display: grid; gap: var(--oz-space-4); justify-items: center; - animation: oz-enter-pop var(--oz-spring-spatial-fast-ms) var(--oz-spring-spatial-fast) both; -} -.veil-icon { color: var(--oz-color-content-brand); } -.veil .box strong { - font-family: var(--oz-font-heading); - font-size: var(--oz-text-heading-md); line-height: var(--oz-leading-heading-md); - letter-spacing: var(--oz-tracking-heading-md); font-weight: var(--oz-default-weight-heading); -} -.veil .box p { margin: 0; color: var(--oz-color-content-secondary); } - -/* Anchored to the top of the shell. At the bottom it sat exactly over the - controls in the evidence pane - a toast that covers the thing you just used - is worse than no toast - and briefly overlaying the settings row costs - nothing, because nobody reads it while a confirmation is up. */ -/* Placement is the whole problem here, and it took three tries. Centred at the - top it sat on the Format and Quality selects; at bottom 84px it sat on the - per-image quality floor input; hard left it would ride over the queue rows - as soon as the list filled. Every band of this workspace is clickable except - one - the middle of the results bar, which holds the totals on the left and - the download actions on the right and nothing in between. The toast sits in - that gap. `probe_theme.mjs` asserts it covers no control, because the next - person to move it will not know any of the above. */ -/* Clear of the floating bottom bar, which is itself `bottom: --oz-space-4`. - A toast that covers a control is a toast that hides the button somebody was - reaching for - probe_theme asserts it outright, over every visible control - on the page, and it caught this the moment the bar started floating. */ -.toast { - position: fixed; left: 50%; bottom: var(--oz-space-15); top: auto; - max-width: min(46ch, calc(100vw - var(--oz-space-7))); - transform: translate(-50%, calc(8px * var(--oz-motion-spatial-scale))); - z-index: var(--oz-layer-toast); - padding: var(--oz-space-4) var(--oz-space-5); border-radius: var(--app-radius-pill); - background: var(--oz-color-surface-overlay); - box-shadow: var(--oz-elevation-large); - font-size: var(--oz-text-body-sm); - opacity: 0; pointer-events: none; - transition: opacity var(--oz-spring-effects-default-ms) var(--oz-spring-effects-default), - transform var(--oz-spring-spatial-default-ms) var(--oz-spring-spatial-default); -} -.toast.on { opacity: 1; transform: translate(-50%, 0); } - -dialog#keys, dialog#alpha-ask { - background: var(--oz-color-surface-overlay); color: var(--oz-color-content-primary); - border: none; border-radius: var(--app-radius-lg); - box-shadow: var(--oz-elevation-large); - padding: var(--oz-space-6) var(--oz-space-7); min-width: 340px; - animation: oz-enter-pop var(--oz-spring-spatial-fast-ms) var(--oz-spring-spatial-fast) both; -} -dialog#keys::backdrop, dialog#alpha-ask::backdrop { - background: var(--oz-overlay-dimness); - backdrop-filter: blur(var(--oz-overlay-blur)); -} -dialog#alpha-ask { max-width: 44ch; } -.dlg-body { - margin: var(--oz-space-4) 0 var(--oz-space-6); - font-size: var(--oz-text-body-sm); line-height: var(--oz-leading-body-sm); - color: var(--oz-color-content-secondary); -} -/* The recommended action leads and wraps last, so the row reads as one - choice with a default rather than three equal buttons. */ -.dlg-actions { display: flex; flex-wrap: wrap; gap: var(--oz-space-3); } -dialog#keys table { - margin: var(--oz-space-4) 0 var(--oz-space-5); - font-size: var(--oz-text-body-sm); border-collapse: collapse; -} -dialog#keys td { padding: var(--oz-space-1) var(--oz-space-5) var(--oz-space-1) 0; border: none; white-space: normal; } -dialog#keys td:first-child { color: var(--oz-color-content-secondary); white-space: nowrap; } - -/* ========================== 5. LEARN MORE ================================ */ -.learn { - max-width: 920px; margin: 0 auto; - padding: var(--oz-space-17) var(--oz-space-7); - content-visibility: auto; contain-intrinsic-size: auto 600px; -} -.learn h2 { - font-family: var(--oz-font-heading); - font-size: var(--oz-text-heading-lg); line-height: var(--oz-leading-heading-lg); - letter-spacing: var(--oz-tracking-heading-lg); font-weight: var(--oz-default-weight-heading); - margin: 0 0 var(--oz-space-4); -} -.learn h3 { - font-family: var(--oz-font-heading); - font-size: var(--oz-text-heading-xs); line-height: var(--oz-leading-heading-xs); - font-weight: var(--oz-default-weight-heading); margin: 0 0 var(--oz-space-3); -} -.learn h3.sub { margin: var(--oz-space-10) 0 var(--oz-space-3); font-size: var(--oz-text-heading-sm); } -.learn p { - color: var(--oz-color-content-secondary); margin: var(--oz-space-4) 0; - font-size: var(--oz-text-body-sm); line-height: var(--oz-leading-body-md); -} -.learn p b, .learn li b { color: var(--oz-color-content-primary); } -.learn .lede { font-size: var(--oz-text-body-md); line-height: var(--oz-leading-body-lg); } -.learn code { - font-family: var(--oz-font-mono); font-size: .92em; - background: var(--oz-color-surface-secondary); - border-radius: var(--app-radius-sm); padding: 1px var(--oz-space-2); -} - -.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--oz-space-4); margin-top: var(--oz-space-7); } -.step { - background: var(--oz-color-surface-primary); - border-radius: var(--app-radius-lg); padding: var(--oz-space-5); -} -.step-n { - display: inline-grid; place-content: center; - width: 26px; height: 26px; border-radius: var(--app-radius-pill); - background: var(--oz-color-fill-brand-secondary); color: var(--oz-color-content-brand); - font-size: var(--oz-text-label-sm); font-weight: var(--oz-weight-semibold); -} -.step h3 { margin: var(--oz-space-4) 0 var(--oz-space-2); font-size: var(--oz-text-body-md); } -.step p { font-size: var(--oz-text-body-sm); margin: 0; } - -.table-wrap { overflow-x: auto; margin: var(--oz-space-6) 0; } -table { border-collapse: collapse; width: 100%; font-size: var(--oz-text-body-sm); } -th, td { - text-align: left; padding: var(--oz-space-3) var(--oz-space-4); - white-space: nowrap; -} -/* A table needs row separation and space cannot provide it inside a grid of - numbers, so this is the one place a hairline earns its keep. Drawn on the - cell's background rather than as a border, so no border job is claimed. */ -tbody tr { background-image: linear-gradient(var(--oz-color-border-primary), var(--oz-color-border-primary)); background-size: 100% 1px; background-repeat: no-repeat; background-position: bottom; } -th { - color: var(--oz-color-content-tertiary); font-family: var(--oz-font-label); - font-size: var(--oz-text-label-xs); letter-spacing: var(--oz-tracking-label-xs); - text-transform: uppercase; font-weight: var(--oz-weight-semibold); -} -td.num { color: var(--oz-color-content-secondary); font-family: var(--oz-font-mono); } -td.win { color: var(--oz-color-content-primary); font-weight: var(--oz-weight-semibold); } -td.win::before { - content: "●"; color: var(--oz-color-content-brand); font-size: 8px; - margin-right: var(--oz-space-2); vertical-align: 2px; -} - -/* ======================= 8. THE COMPARISON PAGE ========================== - compare.html, generated from tests/benchmark.json. It reuses .learn, the - table rules above and the buttons, so the argument looks like the product - making it rather than like a separate document. Only what is genuinely new - to this page lives here. */ -/* Shared with the download page, which opens the same way. Both are standalone - pages rather than app screens, and a second display size for the same job - would make them read as two products. */ -.compare-page h1, -.download-page h1 { - font-family: var(--oz-font-heading); - font-size: var(--oz-text-display-sm); line-height: var(--oz-leading-display-sm); - letter-spacing: var(--oz-tracking-display-sm); - font-weight: var(--oz-default-weight-heading); - margin: 0 0 var(--oz-space-6); max-width: 30ch; -} -/* The three numbers the page rests on. Auto-fit rather than a fixed count, so - a corpus change that adds a claim does not need a breakpoint. */ -.claims { - display: grid; gap: var(--oz-space-5); margin: var(--oz-space-11) 0; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); -} -.claim { - padding: var(--oz-space-6); - background: var(--oz-color-surface-secondary); - border-radius: var(--app-radius-lg); -} -.claim-n { - font-size: var(--oz-text-heading-xl); line-height: 1; - color: var(--oz-color-content-brand); - font-weight: var(--oz-weight-semibold); margin-bottom: var(--oz-space-4); -} -.claim p { - margin: 0; font-size: var(--oz-text-body-sm); - line-height: var(--oz-leading-body-sm); color: var(--oz-color-content-secondary); -} -.corpus-image { margin: var(--oz-space-13) 0; } -.corpus-what { - max-width: 62ch; color: var(--oz-color-content-secondary); - margin: var(--oz-space-3) 0 0; -} -.corpus-meta { - color: var(--oz-color-content-tertiary); font-size: var(--oz-text-body-xs); - margin: var(--oz-space-2) 0 0; -} -/* The smallest file that actually cleared the standard. The only row on the - page that deserves emphasis, so it is the only one that gets it. */ -.compare-page tbody tr.best th, -.compare-page tbody tr.best td { - color: var(--oz-color-content-primary); font-weight: var(--oz-weight-semibold); -} -.compare-page tbody tr.best { background-color: var(--oz-color-fill-brand-secondary); } -/* A row that did not clear it. Dimmed rather than coloured red: it is not an - error, it is a setting somebody chose that did not do what they wanted. */ -.compare-page tbody tr.missed th, -.compare-page tbody tr.missed td { color: var(--oz-color-content-tertiary); } -.compare-page tbody tr.ours th { color: var(--oz-color-content-brand); } -.compare-page .verdict-cell { - white-space: nowrap; font-size: var(--oz-text-body-xs); - color: var(--oz-color-content-tertiary); -} -.compare-page tbody tr.best .verdict-cell { color: var(--oz-color-content-success); } -.repro { - background: var(--oz-color-surface-secondary); - border-radius: var(--app-radius-md); - padding: var(--oz-space-5); overflow-x: auto; - font-family: var(--oz-font-mono); font-size: var(--oz-text-body-xs); - line-height: var(--oz-leading-body-sm); color: var(--oz-color-content-secondary); -} -.cta-line { margin: var(--oz-space-13) 0 0; } - -.checks { padding-left: 0; margin: var(--oz-space-5) 0; list-style: none; display: grid; gap: var(--oz-space-3); } -.checks li { - color: var(--oz-color-content-secondary); padding-left: var(--oz-space-8); position: relative; - font-size: var(--oz-text-body-sm); -} -.checks li::before { - content: "✓"; position: absolute; left: 2px; top: 0; - color: var(--oz-color-content-success); font-weight: var(--oz-weight-semibold); -} - -.code { - background: var(--oz-color-surface-primary); - border-radius: var(--app-radius-md); padding: var(--oz-space-5) var(--oz-space-6); - overflow-x: auto; margin: var(--oz-space-5) 0; -} -.code code { - background: none; padding: 0; - font-size: var(--oz-text-body-sm); line-height: var(--oz-leading-body-lg); -} - -.learn details { - background: var(--oz-color-surface-primary); - border-radius: var(--app-radius-md); margin-top: var(--oz-space-3); - transition: background-color var(--oz-spring-effects-fast-ms) var(--oz-spring-effects-fast); -} -.learn details:hover { background: var(--oz-color-surface-secondary); } -.learn summary { - cursor: pointer; padding: var(--oz-space-4) var(--oz-space-5); - font-weight: var(--oz-weight-medium); font-size: var(--oz-text-body-sm); - list-style: none; position: relative; padding-right: var(--oz-space-11); -} -.learn summary::-webkit-details-marker { display: none; } -.learn summary::after { - content: "+"; position: absolute; right: var(--oz-space-5); top: 50%; - transform: translateY(-50%); color: var(--oz-color-content-tertiary); - font-size: var(--oz-text-body-lg); -} -.learn details[open] summary::after { content: "−"; } -.learn details p { padding: 0 var(--oz-space-5) var(--oz-space-5); margin: 0; } -.learn details.deep { margin-top: var(--oz-space-6); } - -/* --------------------------- theme transition ---------------------------- */ -/* Colour only, and on an effects spring: an overshoot on a colour clips and - stalls. Layout properties are deliberately absent. */ -html.theming body, html.theming body * { - transition-property: background-color, color, border-color, outline-color, box-shadow !important; - transition-duration: var(--oz-spring-effects-default-ms) !important; - transition-timing-function: var(--oz-spring-effects-default) !important; -} - -/* ------------------------------ responsive ------------------------------- */ -@media (max-width: 1000px) { - .body { grid-template-columns: 272px minmax(0, 1fr); } - .details { grid-template-columns: 1fr; max-height: 260px; } - .steps { grid-template-columns: 1fr; } -} -/* Below this the workspace stops being a three-pane inspector and becomes a - phone app: queue on top as a strip, the proof panel underneath. */ -@media (max-width: 760px) { - .site-head { gap: var(--oz-space-4); height: 52px; padding: 0 var(--oz-space-5); } - .site-head nav a:not(:last-child) { display: none; } - .lifetime { display: none; } - /* On a phone the workspace stacks and the page scrolls, so the shell stops - being viewport-bound. */ - .workspace { height: auto; min-height: auto; } - .app { height: auto; } - .body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } - .queue-list { max-height: 168px; } - /* Filename on its own line, actions beneath it. Forced onto one line the four - controls overflowed the pane and Details became unreachable. */ - .insp-head { - padding: var(--oz-space-3) var(--oz-space-4); gap: var(--oz-space-2); - flex-wrap: wrap; align-items: center; - } - .insp-head h2 { - flex: 1 1 100%; font-size: var(--oz-text-body-md); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - } - .insp-head .dims { display: none; } - .insp-head .spacer { display: none; } - .insp-head .btn { padding: 0 var(--oz-space-3); font-size: var(--oz-text-body-sm); } - .insp-title { flex: 1 1 100%; max-width: none; } - .stage { min-height: 320px; } - /* The readout keeps its size on a phone - it is the part that explains what - happened, and the chips are the control. The band scrolls sideways within - itself rather than shrinking, so a chip stays a finger-sized target. */ - .readout { padding: var(--oz-space-4); gap: var(--oz-space-3); } - .narration { font-size: var(--oz-text-body-sm); line-height: var(--oz-leading-body-sm); } - /* Stacked, the panel needs a real cap rather than a share of a pane whose - height is now the page's. */ - #inspector-body > .details { max-height: 320px; } - .body { gap: var(--oz-space-2); padding: var(--oz-space-2); } - .canvas-bar { padding: var(--oz-space-2) var(--oz-space-3); gap: var(--oz-space-2); } - .segmented button { padding: var(--oz-space-1) var(--oz-space-3); font-size: var(--oz-text-body-xs); } - .segmented kbd, .btn kbd { display: none; } - .stats, .stats-lead { grid-template-columns: repeat(2, minmax(0,1fr)); } - .stat-big .v { font-size: var(--oz-text-body-md); } - /* On a phone the bar earns a second line rather than forcing the shell wide. */ - .summary { - padding: var(--oz-space-3) var(--oz-space-4); gap: var(--oz-space-3); - flex-wrap: wrap; height: auto; min-height: 64px; - } - .totals { width: 100%; flex-wrap: wrap; } - .export { margin-left: auto; } - .bandwidth { display: none; } - .empty { padding: var(--oz-space-7) var(--oz-space-4); } - .empty .drop-target { padding: var(--oz-space-9) var(--oz-space-5); } - .learn { padding: var(--oz-space-12) var(--oz-space-5); } - .toast { - top: 60px; bottom: auto; left: var(--oz-space-4); right: var(--oz-space-4); - transform: translateY(calc(-8px * var(--oz-motion-spatial-scale))); text-align: center; - } - .toast.on { transform: translateY(0); } - .hint { display: none; } -} -/* Fingers need bigger targets than cursors - the system names the floor. */ -@media (pointer: coarse) { - .btn, select, .num-input { min-height: var(--oz-target-min); } - /* Dense clusters grow to the normal row height on touch, where 28px is - under the finger target. */ - .btn.sm { min-height: var(--app-control-h); } - .btn.primary { min-height: var(--oz-target-comfortable); } - .segmented button, .zoomer button { min-height: var(--app-control-h); padding: var(--oz-space-2) var(--oz-space-4); } - input[type="range"] { height: 32px; } - input[type="range"]::-webkit-slider-thumb { width: 22px; height: 22px; margin-top: -9.5px; } - input[type="range"]::-moz-range-thumb { width: 22px; height: 22px; } - .row { padding: var(--oz-space-4) var(--oz-space-3); } -} - -/* ======================== 9. THE DOWNLOAD PAGE =========================== - download.html. It reuses .learn, .checks, .code, .metric-note, the buttons, - the site chrome and the comparison page's display heading, so the page that - asks somebody to install this looks like the thing being installed rather - than a landing page bolted on beside it. Only what is new to it lives here. - - The accent budget is why this block is as plain as it is. The page spends - brand orange twice: on the primary action that offers the browser version, - and on whichever build the visitor probably wants. Give the other two cards - an accent too and the guess stops being visible - which is the only thing - the guess is for. */ - -/* Three builds, three equal cards. auto-fit rather than three named columns so - the grid folds to one column on a phone with no breakpoint of its own, and - `min(100%, 220px)` rather than a bare 220px because a fixed track floor is - exactly what makes a grid wider than the screen it is on. */ -.dl-grid { - display: grid; gap: var(--oz-space-4); - grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); - margin: var(--oz-space-7) 0; -} -/* The whole card is the link, so the stroke is the control's boundary rather - than a separator. job: affordance */ -.dl-card { - position: relative; min-width: 0; - display: grid; gap: var(--oz-space-1); align-content: start; - padding: var(--oz-space-6); - border: var(--oz-stroke-2) solid var(--oz-color-border-primary); - border-radius: var(--app-radius-lg); - background: var(--oz-color-surface-primary); - text-decoration: none; - transition-property: background-color, border-color, transform; - transition-duration: var(--oz-spring-effects-fast-ms); - transition-timing-function: var(--oz-spring-effects-fast); -} -.dl-card:hover { - background: var(--oz-color-surface-secondary); - border-color: var(--oz-color-border-primary-hover); -} -/* The same 1px dip as .btn:active, for the same reason: the press has to - register before the download does. */ -.dl-card:active { transform: translateY(1px); } -.dl-os { - font-family: var(--oz-font-heading); - font-size: var(--oz-text-body-lg); line-height: var(--oz-leading-heading-xs); - font-weight: var(--oz-weight-semibold); -} -.dl-arch { font-size: var(--oz-text-body-xs); color: var(--oz-color-content-tertiary); } -.dl-go { - margin-top: var(--oz-space-4); - font-size: var(--oz-text-body-sm); font-weight: var(--oz-weight-medium); -} -/* The guess. Selected-ness is a state, so the stroke may carry it, and the - ground is the brand *surface* step rather than a brand fill - a card is a - surface, and `fill-*` is for controls. It is deliberately the tint and not - the brand itself: this card is still one of three choices and it might be the - wrong one, which is also why the label says "probably". - - `.row[aria-selected="true"]` refuses a brand tint on purpose, because at 15% - over a white page it renders pink and reads destructive rather than selected. - Taken and answered rather than missed: there the tint had to carry "selected" - on its own, here a brand stroke and a brand label already say which card this - is, so the ground only has to agree with them. */ -.dl-card.likely { - border-color: var(--oz-color-border-brand); - background: var(--oz-color-surface-brand); -} -/* Out of the flow. Reserved space would leave a hole in the two cards that - never get the label, and revealing it inside the third would push that - card's platform name out of line with its neighbours'. */ -.dl-you { - position: absolute; top: var(--oz-space-4); right: var(--oz-space-5); - color: var(--oz-color-content-brand); -} - -/* An unsigned build means the operating system will interrupt somebody who - trusted this page, so this line sits at the buttons rather than only in the - section that explains it - which is not where a person who has already - clicked will be. Warning colour, because that is what it is. - - Scoped to the page rather than written as a bare class: `.learn p` is a class - and a type, so it outranks a lone class, and the first version of this line - came out the same grey as the prose around it - the one thing it must not - be. */ -.download-page .dl-heads-up { - color: var(--oz-color-content-warning); - font-size: var(--oz-text-body-sm); max-width: 62ch; -} -.dl-heads-up a { color: inherit; text-decoration-color: currentColor; } - -/* The browser version, given its own ground instead of a line of prose. For - most people it is the right answer, and it has to reach somebody who came - here to download something and stopped reading at the buttons. */ -.dl-alt { - display: grid; gap: var(--oz-space-5); justify-items: start; - margin: var(--oz-space-11) 0; padding: var(--oz-space-7); - background: var(--oz-color-surface-secondary); - border-radius: var(--app-radius-lg); -} -.dl-alt p { - margin: 0; max-width: 54ch; - font-size: var(--oz-text-body-md); color: var(--oz-color-content-primary); -} -/* The one button in this product whose label is a sentence. .btn is nowrap - because a wrapped control breaks a toolbar row, but here the label IS the - offer and it is 46 characters long: held on one line it overhung a 375px - phone by more than a finger's width. */ -.dl-alt .btn { - white-space: normal; text-align: center; - padding-top: var(--oz-space-3); padding-bottom: var(--oz-space-3); -} - -/* The per-platform steps sit inside .learn's
, whose paragraphs carry - the drawer's own padding; the list has to answer to the same edges or the - steps stand outside the prose they belong to. */ -.dl-steps { - margin: 0; - padding: 0 var(--oz-space-5) var(--oz-space-5) var(--oz-space-11); - color: var(--oz-color-content-secondary); - font-size: var(--oz-text-body-sm); line-height: var(--oz-leading-body-md); -} -.dl-steps li + li { margin-top: var(--oz-space-2); } - -/* No blanket motion reset here: the token layer's reduced-motion block already - collapses spatial travel, repoints spatial springs and stops ambient loops, - and a `* { transition: none }` downstream of it would win the cascade and - defeat the graded version - costing those users a snapping interface and - buying nothing, since a colour fade carries no vestibular risk. */ diff --git a/web/app.js b/web/app.js deleted file mode 100644 index e9a0876..0000000 --- a/web/app.js +++ /dev/null @@ -1,2867 +0,0 @@ -/* imgcompress web UI — the desktop app's interface, driving an in-browser - * compression engine (worker.js) instead of a local Python server. - * No network requests are made anywhere in this file. */ - -"use strict"; - -const $ = (id) => document.getElementById(id); -const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches; - -/* ------------------------------- helpers --------------------------------- */ - -function human(n) { - if (!n && n !== 0) return "—"; - const u = ["B", "KB", "MB", "GB"]; - let v = n, i = 0; - while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } - return `${i === 0 ? v.toFixed(0) : v.toFixed(1)} ${u[i]}`; -} - -/* Read from the footer rather than duplicated here: a second copy of a version - string is a second thing to forget, and the exported report was still - claiming 2.2.0 long after the app had moved on. */ -const APP_VERSION = - (document.getElementById("app-version")?.textContent || "").trim().replace(/^v/, "") || "unknown"; - -/** Elapsed time, at a precision a person can actually use. Sub-second work is - * reported in milliseconds because "0.1 s" reads as a rounding artefact, - * and anything past a minute gets minutes because "83.4 s" does not. */ -function duration(ms) { - if (ms == null || !isFinite(ms)) return "—"; - if (ms < 950) return `${Math.max(1, Math.round(ms))} ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(ms < 9950 ? 1 : 0)} s`; - const m = Math.floor(ms / 60000); - return `${m}m ${Math.round((ms - m * 60000) / 1000)}s`; -} - -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); -} - -const SUPPORTED = /\.(png|jpe?g|webp|bmp|tiff?|gif)$/i; - -/* Format names as a person writes them. The engine's keys are lowercase - identifiers; showing those raw made the interface read like debug output. */ -const FORMAT_LABEL = { - jpeg: "JPEG", png8: "PNG-8", png: "PNG", webp: "WebP", - "webp-lossless": "WebP lossless", avif: "AVIF", gif: "GIF", - // Not an encoder: the choice to keep the file exactly as it arrived, which - // is a real candidate and the one the original chip stands for. - original: "Original", -}; -const fmtLabel = (f) => FORMAT_LABEL[f] || (f ? f.toUpperCase() : ""); - -const MIME_OF = { - png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", - bmp: "image/bmp", gif: "image/gif", tif: "image/tiff", tiff: "image/tiff", -}; -function mimeFor(file) { - if (file.type) return file.type; - const ext = (file.name.match(/\.([a-z0-9]+)$/i) || [, ""])[1].toLowerCase(); - return MIME_OF[ext] || "application/octet-stream"; -} - -/** Count-up for byte values: the number rolls to its destination. */ -function rollNumber(el, toBytes, suffixHtml) { - if (REDUCED || toBytes < 1024) { - el.innerHTML = human(toBytes) + (suffixHtml || ""); - return; - } - const t0 = performance.now(), dur = 300; - const tick = (t) => { - const p = Math.min(1, (t - t0) / dur); - const eased = 1 - (1 - p) * (1 - p) * (1 - p); - el.innerHTML = human(toBytes * eased) + (suffixHtml || ""); - if (p < 1) requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); -} - -/* -------------------------------- state ---------------------------------- */ - -const state = { - items: [], - byId: new Map(), - /* Taken from the default destination rather than typed out. Repeating its - frame and quality here made this a fourth copy of two numbers - and one - that only shows up before anything is stored, so a stale value would be - wrong for exactly the people arriving for the first time. - qualityTarget is on the SSIMULACRA 2 scale (0-100); 90 is its published - "visually lossless" line. - `formats` null means the comparison decides; a one-element array means the - person did. `alphaPolicy` only matters when that choice cannot hold the - image's transparency, and is only ever set by answering the dialog. */ - settings: { - target: DEFAULT_DESTINATION, - metric: "ss2", - qualityTarget: DESTINATION_NUMBERS[DEFAULT_DESTINATION].qualityTarget, - maxDimension: DESTINATION_NUMBERS[DEFAULT_DESTINATION].maxDimension, - /* Which edge maxDimension governs. Only ever shrinks: an image already - inside the limit is left alone rather than enlarged. */ - dimensionMode: "longest", - /* Bytes. 0 runs the ordinary search - smallest file that clears the floor. - Non-zero inverts it: the best quality that fits under this. Exactly one - of sizeTarget and "smallest" is ever the goal, which is why the plan - sentence has one control for both and not two. */ - sizeTarget: 0, - formats: null, - alphaPolicy: "png", - }, - settingsRev: 0, - caps: { webp: null, png8: null }, - suffix: false, -}; - -/* DESTINATION_NUMBERS, DESTINATION_ORDER, OLD_TARGET_NAMES and destinationOf() - come from destinations.js, which index.html loads before this file. It is - generated from imgcompress/destinations.py and committed; nothing here - restates a destination's name, frame size or minimum visual match. */ - -/* The control's own options are built from that table too, rather than typed - into index.html - a hand-written list would be one more copy to keep in step, - and this one is the copy a person actually reads. */ -function renderDestinationOptions() { - const sel = $("target"); - if (!sel || sel.children.length) return; - for (const name of DESTINATION_ORDER) { - const d = DESTINATION_NUMBERS[name]; - const opt = document.createElement("option"); - opt.value = name; - // Lower-cased because it lands mid-sentence: "For a website or app, ...". - opt.textContent = d.label.toLowerCase(); - opt.title = d.help; - sel.appendChild(opt); - } -} - -/* Format is its own axis, and its options are the destination's own list. - - These two used to be one control - a single is otherwise as wide as its longest option, which turned this - * sentence into a column of boxes - "indistinguishable" was sized for - * "indistinguishable unless you compare". - * - * The measuring is done by a hidden span and applied as a width, rather than by - * hiding the select behind visible text. That earlier version broke the control - * completely: `color: transparent` on a does not have is silently a no-op - the control -// would sit blank and the stored destination would be lost on the next push. -renderDestinationOptions(); -loadSettings(); -applyTheme(currentThemePref()); -renderLifetime(); -bind(); -render(); -/* render() has mounted the plan somewhere visible, so the picks can finally be - measured - but text width depends on the face actually drawing it, and the - webfonts are still in flight. Fit once more when they land, or every pick - stays sized for a fallback nobody is looking at. */ -if (document.fonts && document.fonts.ready) { - document.fonts.ready.then(fitPicks).catch(() => {}); -} -animateSizeDemo(); -startEngine(); diff --git a/web/compare.html b/web/compare.html deleted file mode 100644 index 04e3b02..0000000 --- a/web/compare.html +++ /dev/null @@ -1,660 +0,0 @@ - - - -How imgcompress compares — measured, at matched quality - - - - - - - - - - - - - -
- - - 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 - 90 out of 100, for all of them. Then the sizes are worth - comparing.

- -
-
-
20/22
-

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.

-
-
-
6/6
-

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 90 you would see it.
  • -
  • A row marked you would see it is not a win however small it is. - That is the entire point.
  • -
- -
-

A 12-megapixel photograph

-

Straight off a camera. The case where guessing a quality number costs the most, because there is the most to lose.

-

2560×1920 - · source 1.9 MB

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
A 12-megapixel photograph: size and visual match per strategy, - every searched strategy held to a visual match of 90
How it was compressedSizevs smallestMatchVerdict
AVIF q50 (a common default)12.3 KB80you would see it
WebP q75 (a common default)22.4 KB76you would see it
JPEG q75 (a common default)79.4 KB80you would see it
JPEG q85 (a common default)178.3 KB85you would see it
imgcompress web (documents)362.4 KBsmallest90close enough
imgcompress web (web)362.4 KBsmallest90close enough
JPEG 4:2:0 only517.4 KB+43%90close enough
imgcompress desktop543.8 KB+50%91close enough
mozjpeg 4:4:4 only543.8 KB+50%91close enough
PNG lossless + zopfli2.6 MB+621%100close enough
-
-
- -
-

A smooth gradient

-

Ruinous for palette formats and nearly free for lossless ones. The clearest case for testing rather than assuming.

-

1000×600 - · source 3.2 KB

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
A smooth gradient: size and visual match per strategy, - every searched strategy held to a visual match of 90
How it was compressedSizevs smallestMatchVerdict
imgcompress desktop438 Bsmallest100close enough
imgcompress web (web)450 B+3%100close enough
AVIF q50 (a common default)1.3 KB86you would see it
WebP q75 (a common default)2.5 KB75you would see it
PNG lossless + zopfli2.7 KB+539%100close enough
imgcompress web (documents)2.8 KB+553%100close enough
JPEG q75 (a common default)6.0 KB77you would see it
JPEG q85 (a common default)9.6 KB83you would see it
-
-
- -
-

A logo with transparency

-

Transparent edges, flat colour. Compared over a dark and a light background, with the worse result counting.

-

900×900 - · source 10.0 KB

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
A logo with transparency: size and visual match per strategy, - every searched strategy held to a visual match of 90
How it was compressedSizevs smallestMatchVerdict
imgcompress web (web)2.9 KBsmallest100close enough
imgcompress desktop3.1 KB+10%100close enough
pngquant + zopfli3.9 KB+36%100close enough
PNG lossless + zopfli3.9 KB+36%100close enough
imgcompress web (documents)4.1 KB+45%100close enough
AVIF q50 (a common default)7.1 KB81you would see it
WebP q75 (a common default)11.1 KB81you would see it
-
-
- -
-

A photograph exported as PNG

-

The mistake almost everybody makes at least once: a photo saved in a format built for flat colour.

-

1280×820 - · source 1.7 MB

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
A photograph exported as PNG: size and visual match per strategy, - every searched strategy held to a visual match of 90
How it was compressedSizevs smallestMatchVerdict
AVIF q50 (a common default)11.0 KB70you would see it
WebP q75 (a common default)15.9 KB62you would see it
JPEG q75 (a common default)48.6 KB70you would see it
JPEG q85 (a common default)82.7 KB74you would see it
imgcompress desktop439.3 KBsmallest91close enough
mozjpeg 4:4:4 only439.3 KBsmallest91close enough
imgcompress web (documents)450.1 KB+2%91close enough
imgcompress web (web)450.1 KB+2%91close enough
PNG lossless + zopfli1.5 MB+249%100close enough
-
-
- -
-

A retina screenshot

-

Text and interface chrome at 2x. Fine detail that lossy formats smear before you notice.

-

2560×1600 - · source 16.5 KB

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
A retina screenshot: size and visual match per strategy, - every searched strategy held to a visual match of 90
How it was compressedSizevs smallestMatchVerdict
imgcompress web (web)1.1 KBsmallest100close enough
imgcompress desktop1.1 KBsmallest100close enough
AVIF only2.5 KB+130%92close enough
AVIF q50 (a common default)2.5 KB+131%92close enough
imgcompress web (documents)4.2 KB+284%100close enough
pngquant + zopfli4.6 KB+322%100close enough
PNG lossless + zopfli4.6 KB+322%100close enough
WebP q75 (a common default)15.1 KB88you would see it
WebP only17.6 KB+1528%90close enough
JPEG q75 (a common default)94.6 KB88you would see it
mozjpeg 4:4:4 only97.8 KB+8940%92close enough
JPEG 4:2:0 only109.6 KB+10031%90close enough
JPEG q85 (a common default)109.6 KB+10031%90close enough
-
-
- -
-

Interface artwork with text

-

Flat colour and hard edges, where palette formats win and photographic ones waste bytes.

-

1280×820 - · source 29.3 KB

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Interface artwork with text: size and visual match per strategy, - every searched strategy held to a visual match of 90
How it was compressedSizevs smallestMatchVerdict
imgcompress desktop6.8 KBsmallest94close enough
pngquant + zopfli6.8 KBsmallest94close enough
AVIF q50 (a common default)7.0 KB88you would see it
imgcompress web (web)9.3 KB+37%100close enough
AVIF only11.2 KB+66%90close enough
WebP q75 (a common default)12.2 KB83you would see it
PNG lossless + zopfli21.5 KB+217%100close enough
imgcompress web (documents)23.4 KB+246%100close enough
JPEG q75 (a common default)31.4 KB79you would see it
JPEG q85 (a common default)35.4 KB83you would see it
mozjpeg 4:4:4 only48.2 KB+612%92close enough
JPEG 4:2:0 only60.5 KB+792%90close enough
-
-
- -

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.

- -

Try it on your own - image

-
diff --git a/web/css/base.css b/web/css/base.css new file mode 100644 index 0000000..1802f4f --- /dev/null +++ b/web/css/base.css @@ -0,0 +1,148 @@ +/* Reset, tokens, and element defaults. + * + * This is a deliberate baseline, not a design system. There are no brand faces, + * no shadow scale, no motion vocabulary and no component classes here - just + * enough for semantic HTML to be legible. Every value below is defined once and + * consumed by name; if a colour or a space is needed twice, it becomes a token + * rather than a second literal. + * + * The palette is greyscale plus one accent, and it is derived rather than + * listed: light and dark redefine the same six names, so nothing downstream + * needs to know which mode it is in. + */ + +:root { + color-scheme: light dark; + + /* Surfaces, back to front. */ + --c-bg: #ffffff; + --c-sunk: #f4f4f5; + --c-raised: #ffffff; + --c-line: #d8d8dc; + + /* Ink, loudest to quietest. */ + --c-ink: #17171a; + --c-ink-2: #55555e; + --c-ink-3: #83838d; + + /* One accent, used for the primary action and nothing decorative. */ + --c-accent: #1d4ed8; + --c-accent-ink: #ffffff; + + /* Verdicts. Never the only carrier of meaning - each is paired with words. */ + --c-good: #157347; + --c-warn: #8a5300; + --c-bad: #b42318; + + /* Space. A 4px base, named by step so a layout reads as a ratio. */ + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + + --radius: 6px; + + /* Two stacks: the UI face is the reader's own, and figures get tabular + digits so a column of sizes lines up and stops jittering as it counts. */ + --font-ui: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-num: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --c-bg: #16161a; + --c-sunk: #1e1e23; + --c-raised: #26262c; + --c-line: #38383f; + + --c-ink: #f2f2f4; + --c-ink-2: #b0b0b8; + --c-ink-3: #83838d; + + --c-accent: #6c93f8; + --c-accent-ink: #10101a; + + --c-good: #55c68d; + --c-warn: #e0aa4a; + --c-bad: #f08a80; + } +} + +*, *::before, *::after { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + background: var(--c-bg); + color: var(--c-ink); + font: 14px/1.5 var(--font-ui); + -webkit-font-smoothing: antialiased; +} + +/* Figures, wherever they appear. Tabular so widths do not shift mid-count. */ +.num { font-family: var(--font-num); font-variant-numeric: tabular-nums; } + +h1, h2, h3 { margin: 0; font-size: 1rem; font-weight: 600; } + +/* Section labels. Small and quiet: the data is the content, not its name. */ +h2, h3 { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--c-ink-3); +} + +p { margin: 0; } + +img, canvas { max-width: 100%; } + +kbd { + font: 11px var(--font-num); + padding: 1px var(--s-1); + border: 1px solid var(--c-line); + border-radius: 3px; + color: var(--c-ink-3); +} + +/* One focus treatment for everything, and it is never removed. */ +:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +} + +/* Reachable by keyboard, out of the way otherwise. */ +.skip { + position: absolute; + left: -9999px; + top: var(--s-2); + padding: var(--s-2) var(--s-3); + background: var(--c-raised); + color: var(--c-ink); + border: 1px solid var(--c-line); + border-radius: var(--radius); + z-index: 10; +} +.skip:focus { left: var(--s-2); } + +/* Available to screen readers, absent from the page. */ +.sr-only { + position: absolute; + width: 1px; height: 1px; + margin: -1px; padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.muted { color: var(--c-ink-3); } +.spacer { flex: 1; } + +[hidden] { display: none !important; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; animation: none !important; } +} diff --git a/web/css/compare.css b/web/css/compare.css new file mode 100644 index 0000000..70a302d --- /dev/null +++ b/web/css/compare.css @@ -0,0 +1,214 @@ +/* The stage: the original and the result, on top of each other. + * + * The comparison is the product, so it gets the largest region and the fewest + * ornaments. Two bars float over it and nothing else does - mode and zoom above, + * identity and output below. + * + * Both images sit inside one #frame at the original's natural pixel size, and + * the frame is what zooms and pans. That is the reason they cannot drift apart: + * a single transform moves both, so any pixel of the result is over the pixel of + * the original it replaced at every zoom level. The split is applied as a clip + * on the top layer, in the frame's own coordinates, which JS recomputes from the + * divider's position on screen - so the caliper cuts where it appears to cut + * however far the image has been pushed around. + */ + +#view { + position: absolute; + inset: 0; + overflow: hidden; + cursor: grab; + touch-action: none; +} +#view[data-panning="1"] { cursor: grabbing; } + +/* Sized in JS to the original's natural pixel dimensions, which is almost always + larger than the region it sits in. + * + * Centring is done by the transform, not by the layout. This was a grid with + * `place-items: center` first, and it does not work: an item bigger than its grid + * area is clamped to the start edge rather than overflowing evenly, so a 1400px + * frame in a 1080px region sat hard against the left and hung off the bottom. + * `left/top: 50%` plus `translate(-50%, -50%)` centres on the element's own box + * and cannot be clamped, at any zoom. */ +#frame { + position: absolute; + left: 50%; + top: 50%; + /* Checkerboard, so transparency reads as transparency rather than as white. + Two gradients at 16px is the whole trick; it costs no image request. */ + background-color: #ffffff; + background-image: + linear-gradient(45deg, #e6e6e8 25%, transparent 25%, transparent 75%, #e6e6e8 75%), + linear-gradient(45deg, #e6e6e8 25%, transparent 25%, transparent 75%, #e6e6e8 75%); + background-size: 16px 16px; + background-position: 0 0, 8px 8px; +} + +/* Every layer fills the frame. The result is stretched to the original's box on + purpose: resizing is part of the plan, and what is being compared is how the + two look at the size they will be seen at, not their pixel grids. */ +#frame > img, +#frame > .after, +#frame > canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; +} +#frame > .after > img { width: 100%; height: 100%; display: block; } + +/* Pixels stay pixels past 1:1. Inspecting compression artefacts through a + smoothing filter would be inspecting the filter. */ +#view[data-sharp="1"] #frame img, +#view[data-sharp="1"] #frame canvas { image-rendering: pixelated; } + +.after { clip-path: inset(0 0 0 var(--clip, 50%)); } + +/* Mode is carried on the view, so switching it is one attribute rather than a + set of hidden flags that could disagree with each other. */ +#view[data-mode="after"] .after { clip-path: none; } +#view[data-mode="diff"] .after { display: none; } +#view:not([data-mode="diff"]) #img-diff { display: none; } +#view[data-mode="diff"] #img-diff { display: block; } + +/* ------------------------------ the caliper ------------------------------ */ + +/* Full height of the viewport, not of the image: it is a measuring edge on the + screen, and the two labels ride it so the reading is attached to the thing + being read rather than parked in a corner. */ +#divider { + position: absolute; + top: 0; bottom: 0; + left: 50%; + width: 1px; + background: rgb(255 255 255 / 0.9); + box-shadow: 0 0 0 1px rgb(0 0 0 / 0.35); + pointer-events: none; +} +#divider > .tag { + position: absolute; + top: var(--s-3); + padding: 2px var(--s-2); + border-radius: 3px; + background: rgb(0 0 0 / 0.66); + color: #fff; + font-size: 11px; + white-space: nowrap; +} +#divider > .tag.l { right: var(--s-2); } +#divider > .tag.r { left: var(--s-2); } + +/* The real control over the split: a native range, invisible but present, so + the divider is draggable with a mouse and arrow-steppable with a keyboard + without any of that being reimplemented here. */ +#split { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: ew-resize; + -webkit-appearance: none; + appearance: none; + background: transparent; +} +#split::-webkit-slider-thumb { + -webkit-appearance: none; + width: 40px; + height: 100vh; + cursor: ew-resize; +} +#split::-moz-range-thumb { + width: 40px; + height: 100vh; + border: 0; + border-radius: 0; + background: transparent; + cursor: ew-resize; +} + +/* Only in split mode is there anything to drag. */ +#view:not([data-mode="split"]) ~ #split, +#view:not([data-mode="split"]) #divider { display: none; } + +/* ------------------------------ floating bars ---------------------------- */ + +/* Wraps on a narrow screen. The bottom bar carries the name, the extension, two + figures and two buttons; on a phone that is more than one row's worth, and + squeezing it onto one row is what cut the filename down to four legible + characters. */ +.stage-bar { + position: absolute; + left: var(--s-3); + right: var(--s-3); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-2); + padding: var(--s-2); + border: 1px solid var(--c-line); + border-radius: var(--radius); + background: var(--c-bg); + z-index: 2; +} +.stage-bar.top { top: var(--s-3); } +.stage-bar.bottom { bottom: var(--s-3); } + +/* The name is editable in place: renaming is an edit, not a fact about the + result, so it looks like text until it is being typed in. */ +/* Wide enough that a real filename is still a filename. 20ch left a 60-character + screenshot name showing 19 legible characters, which is not enough to tell two + exports of the same screen apart - the one thing the name is for. */ +#out-name { + flex: 1 1 30ch; + min-width: 12ch; + max-width: 44ch; + border-color: transparent; + background: transparent; +} +#out-name:hover:not(:focus) { border-color: var(--c-line); } + +.stage-bar .size { font-size: 13px; color: var(--c-ink-2); } +.stage-bar .saved { font-size: 13px; color: var(--c-good); } + +.zoom { display: inline-flex; align-items: center; gap: var(--s-1); } +.zoom > .z { min-width: 5ch; text-align: center; font-size: 12px; color: var(--c-ink-2); } + +/* ------------------------------ stage empty ------------------------------ */ + +/* What is true when there is nothing to compare, said plainly. Some files this + browser cannot decode at all, and this is also where that is said - the + honest substitute for a preview is a sentence, not a broken-image glyph. */ +#stage-empty { + position: absolute; + inset: 0; + display: grid; + place-content: center; + padding: var(--s-5); + text-align: center; + color: var(--c-ink-3); +} + +/* ------------------------------- progress -------------------------------- */ + +/* While an image is being measured, the stage shows the untouched original and + this line over it. Never a bare spinner: it names the format being tried, so + the wait is legible rather than merely long. */ +#stage-work { + position: absolute; + left: 50%; + bottom: 72px; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-2) var(--s-4); + border-radius: var(--radius); + background: rgb(0 0 0 / 0.72); + color: #fff; + font-size: 13px; + z-index: 2; +} diff --git a/web/css/controls.css b/web/css/controls.css new file mode 100644 index 0000000..0244841 --- /dev/null +++ b/web/css/controls.css @@ -0,0 +1,183 @@ +/* Controls: buttons, native form fields, the toast, the one dialog. + * + * Two button weights and no more. `.btn` is every action; `.btn.primary` is the + * one action a region is for, and a region gets at most one. Everything else - + * quieter, smaller, iconic - is the same button with less padding, because a + * third weight is a third thing to decide and nobody could tell the second from + * the third anyway. + * + * The form fields are native and stay native. A 's intrinsic minimum is + the width of its longest