fxcss 0.16.2__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {fxcss-0.16.2 → fxcss-0.18.0}/PKG-INFO +35 -6
  2. {fxcss-0.16.2 → fxcss-0.18.0}/README.md +34 -5
  3. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/__init__.py +1 -1
  4. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/cli.py +29 -0
  5. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/templates/firefox-watch.yml +69 -0
  6. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/templates/pr-preview.yml +49 -0
  7. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/tweaks.py +48 -0
  8. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss.egg-info/PKG-INFO +35 -6
  9. {fxcss-0.16.2 → fxcss-0.18.0}/tests/test_units.py +70 -2
  10. {fxcss-0.16.2 → fxcss-0.18.0}/LICENSE +0 -0
  11. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/__main__.py +0 -0
  12. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/adopt.py +0 -0
  13. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/audit.py +0 -0
  14. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/catalogue.py +0 -0
  15. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/compare.py +0 -0
  16. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/complete.py +0 -0
  17. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/core.py +0 -0
  18. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/fetch.py +0 -0
  19. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/install.py +0 -0
  20. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/probe.py +0 -0
  21. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/scaffold.py +0 -0
  22. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/sheets.py +0 -0
  23. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/templates/pr-preview-cleanup.yml +0 -0
  24. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/templates/pr-preview-publish.yml +0 -0
  25. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/templates/readme-previews.yml +0 -0
  26. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/templates/showcase.yml +0 -0
  27. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/templates/starter/chrome/userChrome.css +0 -0
  28. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss/templates/starter/custom/accent-red.css +0 -0
  29. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss.egg-info/SOURCES.txt +0 -0
  30. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss.egg-info/dependency_links.txt +0 -0
  31. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss.egg-info/entry_points.txt +0 -0
  32. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss.egg-info/requires.txt +0 -0
  33. {fxcss-0.16.2 → fxcss-0.18.0}/fxcss.egg-info/top_level.txt +0 -0
  34. {fxcss-0.16.2 → fxcss-0.18.0}/pyproject.toml +0 -0
  35. {fxcss-0.16.2 → fxcss-0.18.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fxcss
3
- Version: 0.16.2
3
+ Version: 0.18.0
4
4
  Summary: Live-reload, inspect and screenshot-test Firefox userChrome.css themes
5
5
  Author: AdamXweb
6
6
  License: MIT
@@ -122,7 +122,7 @@ the [releases page](https://github.com/AdamXweb/fxcss/releases) has the latest.
122
122
  CI runners' Pythons are not externally managed, so plain pip is fine there:
123
123
 
124
124
  ```bash
125
- pip install "fxcss[images]==0.16.2"
125
+ pip install "fxcss[images]==0.18.0"
126
126
  ```
127
127
 
128
128
  Either gives you an `fxcss` command. To hack on it, clone and install editable:
@@ -322,9 +322,11 @@ screenshotting a `--combo` that cannot take effect.
322
322
  > fighting over the same property. Two sheets that rearrange the same area
323
323
  > through *different* selectors — WhiteSur's `tabs-swapclose` and
324
324
  > `windows-swapclose` both move a close button and share no declarations at
325
- > all — are invisible to it. `fxcss tweaks --combo a+b` is what proves those:
326
- > if `a+b` renders identically to `b` alone, `a` was overridden. Silence here
327
- > is "nothing measurable", never "verified compatible".
325
+ > all — are invisible to it. `fxcss tweaks --combo a+b` proves those from
326
+ > the rendered pixels — and says so itself: a combo that renders identically
327
+ > to one of its parts alone is called out in TWEAKS.md as not a real
328
+ > combination on that Firefox. Silence from the static check is "nothing
329
+ > measurable", never "verified compatible"; the pixels are the judge.
328
330
 
329
331
  When the theme's default branch has moved on since its newest release, that
330
332
  choice is put to you as well — a tag can be a year behind a fix you are
@@ -369,6 +371,7 @@ Restart Firefox after either command; it reads `userChrome.css` at startup.
369
371
  fxcss upgrade # take the newest version of what you have
370
372
  fxcss upgrade --check # report only; exit code says what it found
371
373
  fxcss upgrade --audit # check the new version against your Firefox first
374
+ fxcss upgrade --compare # see it before deciding: installed vs new, rendered
372
375
  fxcss upgrade --ref v2.1.0 # somewhere specific
373
376
  fxcss rollback # …and back again
374
377
  ```
@@ -391,6 +394,15 @@ It stops rather than surprise you, in three places:
391
394
  which runs the same check as [`fxcss audit`](#fxcss-audit) against the
392
395
  fetched copy *before* anything is installed.
393
396
 
397
+ `--compare` renders what is installed and what the upgrade would install —
398
+ the profile's own `chrome/`, local edits and carried-over sheets included, not
399
+ a re-fetch of what the manifest says — and diffs them before the confirmation
400
+ prompt, printing where the before/after images landed. It completes the three
401
+ questions an upgrade can answer about a theme with no API: do the selectors
402
+ still exist (`--audit`), has the user edited anything (always checked), and
403
+ what actually changes on screen (`--compare`). Needs Pillow and a local
404
+ Firefox, like `shot`.
405
+
394
406
  `--check` changes nothing and answers with its exit code, for cron, launchd or
395
407
  CI: **0** up to date, **1** an upgrade is available, **2** it cannot be told
396
408
  (no install here, an unreachable repo, or a manifest too old to say what it
@@ -681,6 +693,16 @@ overwritten without `--force`, and the output tells you the things that
681
693
  otherwise surprise people (the comment starts after the workflows reach your
682
694
  default branch; first-time contributors need one approval click).
683
695
 
696
+ Each render captures the base branch and the pull request and diffs them. The
697
+ base half is cached: its captures are deterministic — that is the contract
698
+ fxcss's own CI asserts — so after a pull request's first push, later pushes
699
+ render only the change. The key names everything that could alter a pixel
700
+ (base commit, runner image, Firefox version, fxcss version, the workflow file
701
+ itself), and a miss on any of them re-renders rather than falling back to a
702
+ near match. GitHub scopes caches to the branch that made them, so this helps
703
+ the second push onward, not the first: a pull request that takes six pushes
704
+ to land renders the base once, not six times.
705
+
684
706
  If you'd like people to know:
685
707
 
686
708
  [![theme previews by fxcss](https://img.shields.io/badge/theme%20previews-fxcss-ff7139)](https://github.com/AdamXweb/fxcss)
@@ -699,6 +721,13 @@ renders the answer instead: the base theme, every optional stylesheet, and any
699
721
  combination you bless with `--combo`, each with a labelled **before/after crop
700
722
  of the region it actually changes** and how much of the chrome it touches.
701
723
 
724
+ A `--combo` is also judged against its own parts: if `a+b` renders
725
+ pixel-identically to `b` alone, then `a` did nothing in that combination, and
726
+ TWEAKS.md says so rather than presenting the pair as a real option. That is
727
+ the check the static conflict analysis in `install` cannot make — two sheets
728
+ can fight over the same pixels through entirely different rules — and the
729
+ rendered images settle it as a fact rather than a judgement.
730
+
702
731
  The crop is built from the changed pixels, so it needs no per-option
703
732
  configuration and cannot drift when Firefox moves something. It centres on the
704
733
  busiest *cluster* of changes rather than the bounding box of all of them,
@@ -1013,7 +1042,7 @@ check out the base revision and the pull request revision, render both, compare,
1013
1042
  and publish the result.
1014
1043
 
1015
1044
  ```yaml
1016
- - run: pip install "fxcss[images]==0.16.2" # pin: your CI, your upgrades
1045
+ - run: pip install "fxcss[images]==0.18.0" # pin: your CI, your upgrades
1017
1046
  - run: fxcss shot --theme base --out shots/base
1018
1047
  - run: fxcss shot --theme head --out shots/head
1019
1048
  - run: fxcss compare --base shots/base --head shots/head --out out/ --platform ${{ runner.os }}
@@ -99,7 +99,7 @@ the [releases page](https://github.com/AdamXweb/fxcss/releases) has the latest.
99
99
  CI runners' Pythons are not externally managed, so plain pip is fine there:
100
100
 
101
101
  ```bash
102
- pip install "fxcss[images]==0.16.2"
102
+ pip install "fxcss[images]==0.18.0"
103
103
  ```
104
104
 
105
105
  Either gives you an `fxcss` command. To hack on it, clone and install editable:
@@ -299,9 +299,11 @@ screenshotting a `--combo` that cannot take effect.
299
299
  > fighting over the same property. Two sheets that rearrange the same area
300
300
  > through *different* selectors — WhiteSur's `tabs-swapclose` and
301
301
  > `windows-swapclose` both move a close button and share no declarations at
302
- > all — are invisible to it. `fxcss tweaks --combo a+b` is what proves those:
303
- > if `a+b` renders identically to `b` alone, `a` was overridden. Silence here
304
- > is "nothing measurable", never "verified compatible".
302
+ > all — are invisible to it. `fxcss tweaks --combo a+b` proves those from
303
+ > the rendered pixels — and says so itself: a combo that renders identically
304
+ > to one of its parts alone is called out in TWEAKS.md as not a real
305
+ > combination on that Firefox. Silence from the static check is "nothing
306
+ > measurable", never "verified compatible"; the pixels are the judge.
305
307
 
306
308
  When the theme's default branch has moved on since its newest release, that
307
309
  choice is put to you as well — a tag can be a year behind a fix you are
@@ -346,6 +348,7 @@ Restart Firefox after either command; it reads `userChrome.css` at startup.
346
348
  fxcss upgrade # take the newest version of what you have
347
349
  fxcss upgrade --check # report only; exit code says what it found
348
350
  fxcss upgrade --audit # check the new version against your Firefox first
351
+ fxcss upgrade --compare # see it before deciding: installed vs new, rendered
349
352
  fxcss upgrade --ref v2.1.0 # somewhere specific
350
353
  fxcss rollback # …and back again
351
354
  ```
@@ -368,6 +371,15 @@ It stops rather than surprise you, in three places:
368
371
  which runs the same check as [`fxcss audit`](#fxcss-audit) against the
369
372
  fetched copy *before* anything is installed.
370
373
 
374
+ `--compare` renders what is installed and what the upgrade would install —
375
+ the profile's own `chrome/`, local edits and carried-over sheets included, not
376
+ a re-fetch of what the manifest says — and diffs them before the confirmation
377
+ prompt, printing where the before/after images landed. It completes the three
378
+ questions an upgrade can answer about a theme with no API: do the selectors
379
+ still exist (`--audit`), has the user edited anything (always checked), and
380
+ what actually changes on screen (`--compare`). Needs Pillow and a local
381
+ Firefox, like `shot`.
382
+
371
383
  `--check` changes nothing and answers with its exit code, for cron, launchd or
372
384
  CI: **0** up to date, **1** an upgrade is available, **2** it cannot be told
373
385
  (no install here, an unreachable repo, or a manifest too old to say what it
@@ -658,6 +670,16 @@ overwritten without `--force`, and the output tells you the things that
658
670
  otherwise surprise people (the comment starts after the workflows reach your
659
671
  default branch; first-time contributors need one approval click).
660
672
 
673
+ Each render captures the base branch and the pull request and diffs them. The
674
+ base half is cached: its captures are deterministic — that is the contract
675
+ fxcss's own CI asserts — so after a pull request's first push, later pushes
676
+ render only the change. The key names everything that could alter a pixel
677
+ (base commit, runner image, Firefox version, fxcss version, the workflow file
678
+ itself), and a miss on any of them re-renders rather than falling back to a
679
+ near match. GitHub scopes caches to the branch that made them, so this helps
680
+ the second push onward, not the first: a pull request that takes six pushes
681
+ to land renders the base once, not six times.
682
+
661
683
  If you'd like people to know:
662
684
 
663
685
  [![theme previews by fxcss](https://img.shields.io/badge/theme%20previews-fxcss-ff7139)](https://github.com/AdamXweb/fxcss)
@@ -676,6 +698,13 @@ renders the answer instead: the base theme, every optional stylesheet, and any
676
698
  combination you bless with `--combo`, each with a labelled **before/after crop
677
699
  of the region it actually changes** and how much of the chrome it touches.
678
700
 
701
+ A `--combo` is also judged against its own parts: if `a+b` renders
702
+ pixel-identically to `b` alone, then `a` did nothing in that combination, and
703
+ TWEAKS.md says so rather than presenting the pair as a real option. That is
704
+ the check the static conflict analysis in `install` cannot make — two sheets
705
+ can fight over the same pixels through entirely different rules — and the
706
+ rendered images settle it as a fact rather than a judgement.
707
+
679
708
  The crop is built from the changed pixels, so it needs no per-option
680
709
  configuration and cannot drift when Firefox moves something. It centres on the
681
710
  busiest *cluster* of changes rather than the bounding box of all of them,
@@ -990,7 +1019,7 @@ check out the base revision and the pull request revision, render both, compare,
990
1019
  and publish the result.
991
1020
 
992
1021
  ```yaml
993
- - run: pip install "fxcss[images]==0.16.2" # pin: your CI, your upgrades
1022
+ - run: pip install "fxcss[images]==0.18.0" # pin: your CI, your upgrades
994
1023
  - run: fxcss shot --theme base --out shots/base
995
1024
  - run: fxcss shot --theme head --out shots/head
996
1025
  - run: fxcss compare --base shots/base --head shots/head --out out/ --platform ${{ runner.os }}
@@ -1,3 +1,3 @@
1
1
  """fxcss — a testing toolkit for Firefox userChrome.css themes."""
2
2
 
3
- __version__ = "0.16.2"
3
+ __version__ = "0.18.0"
@@ -1048,6 +1048,31 @@ def cmd_upgrade(args):
1048
1048
  "upgrade anyway.", file=sys.stderr)
1049
1049
  return 1
1050
1050
 
1051
+ if args.compare:
1052
+ # Seeing beats being told: render what is installed and what the
1053
+ # upgrade would install, and diff them, before the question is
1054
+ # asked. The profile itself is the theme root for the installed
1055
+ # side -- its chrome/ IS the theme as the user actually has it,
1056
+ # local edits and carried-over sheets included, which no re-fetch
1057
+ # of the recorded ref could reproduce.
1058
+ try:
1059
+ from . import compare as compare_mod
1060
+ except ImportError as exc:
1061
+ return _needs_pillow("upgrade --compare", exc)
1062
+ import tempfile
1063
+ firefox = choose_firefox(args.firefox)
1064
+ outdir = Path(tempfile.mkdtemp(prefix="fxcss-upgrade-compare-"))
1065
+ print("\n rendering what is installed now …")
1066
+ with core.Session(Path(picked["path"]), firefox) as session:
1067
+ core.capture_views(session, outdir / "installed")
1068
+ print(f"\n rendering {why} …")
1069
+ with core.Session(theme_root, firefox) as session:
1070
+ core.capture_views(session, outdir / "new")
1071
+ print()
1072
+ compare_mod.run(outdir / "installed", outdir / "new",
1073
+ outdir / "diff", "upgrade")
1074
+ print(f" before/after images: {outdir / 'diff'}")
1075
+
1051
1076
  if install.firefox_running(picked["path"]):
1052
1077
  print("\n note: Firefox appears to be running — the change will "
1053
1078
  "only show after a restart.")
@@ -1778,6 +1803,10 @@ def build_parser():
1778
1803
  help="report only, changing nothing. Exit 0 when up to "
1779
1804
  "date, 1 when an upgrade is available, 2 when it "
1780
1805
  "cannot be told — for cron and CI")
1806
+ up.add_argument("--compare", action="store_true",
1807
+ help="render the installed theme and the new version, "
1808
+ "and show what changes, before asking (needs Pillow "
1809
+ "and a local Firefox)")
1781
1810
  up.add_argument("--audit", action="store_true",
1782
1811
  help="before installing, check the new version's "
1783
1812
  "selectors against a real Firefox")
@@ -91,6 +91,74 @@ jobs:
91
91
  echo "version=${version:-unknown}" >> "$GITHUB_OUTPUT"
92
92
  if [ -s fxcss-fix.patch ]; then echo "patch=yes" >> "$GITHUB_OUTPUT"; fi
93
93
 
94
+ # The audit sees selectors; it does not see the capture pipeline. When
95
+ # Firefox 154 moved a module out from under fxcss, every preview render
96
+ # broke on release day while a (then healthy) audit-only watch would
97
+ # have stayed green -- beta had the breakage four days early and nothing
98
+ # could say so. One standard capture per channel is the canary for that:
99
+ # if this fails on beta, PR previews fail when that Firefox reaches
100
+ # release, and the usual fix is an fxcss update rather than a theme one.
101
+ - name: Capture smoke
102
+ id: smoke
103
+ if: ${{ github.event.inputs.channel == '' || github.event.inputs.channel == matrix.channel }}
104
+ run: |
105
+ set +e
106
+ fxcss shot --out "$RUNNER_TEMP/smoke" > smoke.txt 2>&1
107
+ status=$?
108
+ tail -40 smoke.txt
109
+ echo "status=$status" >> "$GITHUB_OUTPUT"
110
+
111
+ - name: Open or update the capture-pipeline issue
112
+ if: steps.smoke.outputs.status != '0'
113
+ env:
114
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
115
+ CHANNEL: ${{ matrix.channel }}
116
+ VERSION: ${{ steps.audit.outputs.version }}
117
+ run: |
118
+ set -euo pipefail
119
+ title="Firefox ${CHANNEL}: the capture pipeline broke"
120
+ {
121
+ echo "Rendering this theme against Firefox **${CHANNEL}** (**${VERSION}**) crashed"
122
+ echo "outright -- not a styling regression, the capture itself. When this"
123
+ echo "Firefox reaches release, the PR preview and README preview workflows"
124
+ echo "will fail the same way."
125
+ echo
126
+ echo "This is usually Firefox moving something out from under fxcss (as"
127
+ echo "Firefox 154 did), so the fix is typically a newer fxcss release and a"
128
+ echo "pin bump here -- check https://github.com/AdamXweb/fxcss/releases and"
129
+ echo "open an issue there if nothing newer exists."
130
+ echo
131
+ echo "The last lines of the failed capture:"
132
+ echo
133
+ echo '```'
134
+ tail -40 smoke.txt
135
+ echo '```'
136
+ echo
137
+ echo "_Updated automatically. This closes itself when the channel is clean._"
138
+ } > smoke-body.md
139
+ existing=$(gh issue list --state open --search "$title in:title" \
140
+ --json number,title --jq "[.[] | select(.title == \"$title\")][0].number")
141
+ if [ -n "$existing" ] && [ "$existing" != "null" ]; then
142
+ gh issue edit "$existing" --body-file smoke-body.md
143
+ else
144
+ gh issue create --title "$title" --body-file smoke-body.md
145
+ fi
146
+
147
+ - name: Close the capture-pipeline issue if this channel is clean again
148
+ if: steps.smoke.outputs.status == '0'
149
+ env:
150
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
151
+ CHANNEL: ${{ matrix.channel }}
152
+ run: |
153
+ set -euo pipefail
154
+ title="Firefox ${CHANNEL}: the capture pipeline broke"
155
+ existing=$(gh issue list --state open --search "$title in:title" \
156
+ --json number,title --jq "[.[] | select(.title == \"$title\")][0].number")
157
+ if [ -n "$existing" ] && [ "$existing" != "null" ]; then
158
+ gh issue close "$existing" \
159
+ --comment "Firefox ${CHANNEL} captures cleanly again -- closing."
160
+ fi
161
+
94
162
  - name: Open a pull request with the confident fixes
95
163
  if: steps.audit.outputs.status != '0' && steps.audit.outputs.patch == 'yes'
96
164
  env:
@@ -182,6 +250,7 @@ jobs:
182
250
  name: audit-${{ matrix.channel }}
183
251
  path: |
184
252
  audit.txt
253
+ smoke.txt
185
254
  fxcss-fix.patch
186
255
  if-no-files-found: ignore
187
256
  retention-days: 30
@@ -122,9 +122,58 @@ jobs:
122
122
  - name: Environment
123
123
  run: fxcss doctor --theme head
124
124
 
125
+ # The base branch renders identically every time -- that determinism is
126
+ # the contract fxcss's own CI asserts -- so after the first push to a
127
+ # pull request there is no reason to render it again. The key names
128
+ # everything that could change the pixels: the base commit, the runner
129
+ # image, the Firefox that happened to be current, the fxcss release and
130
+ # this workflow file. A miss on any of them re-renders; there is no
131
+ # restore-keys fallback, because a near-match is a different render.
132
+ #
133
+ # GitHub scopes caches to the branch that made them, so this helps the
134
+ # second and later pushes of a pull request, not its first.
135
+ - name: Base render cache key
136
+ id: basekey
137
+ shell: bash
138
+ run: |
139
+ firefox=$(python - <<'EOF'
140
+ import configparser, os, pathlib
141
+ binary = pathlib.Path(os.environ["FIREFOX_BIN"])
142
+ for ini in (binary.parent / "application.ini",
143
+ binary.parent.parent / "Resources" / "application.ini"):
144
+ if ini.is_file():
145
+ cfg = configparser.ConfigParser(); cfg.read(ini, encoding="utf-8")
146
+ print(cfg.get("App", "Version", fallback="unknown")); break
147
+ else:
148
+ print("unknown")
149
+ EOF
150
+ )
151
+ echo "firefox=$firefox" >> "$GITHUB_OUTPUT"
152
+ echo "image=${ImageVersion:-unknown}" >> "$GITHUB_OUTPUT"
153
+ echo "base render key: firefox=$firefox image=${ImageVersion:-unknown}"
154
+
155
+ - name: Restore base render
156
+ id: basecache
157
+ uses: actions/cache/restore@v5
158
+ with:
159
+ path: shots/base
160
+ key: >-
161
+ base-render-${{ matrix.label }}-${{ github.event.pull_request.base.sha }}-ff${{ steps.basekey.outputs.firefox }}-img${{ steps.basekey.outputs.image }}-fxcss${{ env.FXCSS_VERSION }}-${{ hashFiles('head/.github/workflows/pr-preview.yml') }}
162
+
125
163
  - name: Render base revision
164
+ if: steps.basecache.outputs.cache-hit != 'true'
126
165
  run: fxcss shot --theme base --out shots/base --variants all
127
166
 
167
+ # Saved the moment the base render succeeds rather than when the job
168
+ # ends: a push that supersedes this run cancels it, and the base is
169
+ # usually done by then -- which is exactly the run the next push wants.
170
+ - name: Save base render
171
+ if: steps.basecache.outputs.cache-hit != 'true'
172
+ uses: actions/cache/save@v5
173
+ with:
174
+ path: shots/base
175
+ key: ${{ steps.basecache.outputs.cache-primary-key }}
176
+
128
177
  - name: Render pull request revision
129
178
  run: fxcss shot --theme head --out shots/head --variants all
130
179
 
@@ -200,6 +200,7 @@ def build(session, theme: Path, outdir: Path, variants, flags):
200
200
  _shrink(base, FULL_WIDTH).save(outdir / "base.png", optimize=True)
201
201
 
202
202
  entries = []
203
+ captures = {}
203
204
  for slug, sheets in sorted(variants.items()):
204
205
  for sheet in sheets:
205
206
  session.m.script(core.LOAD_VARIANT_SHEET, [sheet.resolve().as_uri()])
@@ -218,15 +219,55 @@ def build(session, theme: Path, outdir: Path, variants, flags):
218
219
  else:
219
220
  (outdir / f"{slug}.png").unlink()
220
221
  entries.append(entry)
222
+ captures[slug] = after
221
223
  state = f"{pct:.2f}% of the chrome" if changed else "nothing on this Firefox"
222
224
  print(f" {slug}: changes {state}", flush=True)
223
225
 
226
+ for entry in entries:
227
+ matched = combo_verdict(entry, captures)
228
+ if matched:
229
+ entry["same_as"] = matched
230
+ print(f" {entry['slug']}: renders identically to {matched} alone "
231
+ "— the other sheet(s) have no effect in this combination",
232
+ flush=True)
233
+
224
234
  info = session.info()
225
235
  markdown = render_markdown(theme, entries, flags, info["version"])
226
236
  (outdir / "TWEAKS.md").write_text(markdown, encoding="utf-8")
227
237
  return entries
228
238
 
229
239
 
240
+ def combo_verdict(entry, captures):
241
+ """The single option a combo capture is pixel-identical to, or None.
242
+
243
+ Static analysis can say two sheets set the same declarations; it cannot
244
+ see two sheets that fight over the same pixels through different rules --
245
+ WhiteSur's tabs-swapclose and windows-swapclose both move the tab close
246
+ button while sharing no declarations at all. The rendered images settle
247
+ it: if `a+b` is identical to `b` alone, then `a` did nothing in that
248
+ combination, and that is a fact about pixels rather than a judgement.
249
+ Identical means what it means everywhere else in fxcss -- zero pixels
250
+ changed above diff_stats' noise threshold.
251
+
252
+ Only combinations are judged, and only against their own constituents:
253
+ `a+b` matching some unrelated option c would be a coincidence worth
254
+ nothing. Returns the constituent's slug, or None when the combo really is
255
+ more than any one of its parts.
256
+ """
257
+ parts = entry["slug"].split("+")
258
+ if len(parts) < 2 or entry["slug"] not in captures:
259
+ return None
260
+ combo = captures[entry["slug"]]
261
+ for part in parts:
262
+ single = captures.get(part)
263
+ if single is None or single.size != combo.size:
264
+ continue
265
+ changed, _, _ = diff_stats(single, combo)
266
+ if changed == 0:
267
+ return part
268
+ return None
269
+
270
+
230
271
  def render_markdown(theme: Path, entries, flags, firefox_version):
231
272
  """The committable document. Pure, so it is unit-testable."""
232
273
  lines = [
@@ -261,6 +302,13 @@ def render_markdown(theme: Path, entries, flags, firefox_version):
261
302
  lines.append("")
262
303
  lines.append(f"Full window: [{slug}.png]({slug}.png)")
263
304
  lines.append("")
305
+ if entry.get("same_as"):
306
+ others = " + ".join(part for part in slug.split("+")
307
+ if part != entry["same_as"]) or "the rest"
308
+ lines.append(f"**Not a real combination on this Firefox:** it "
309
+ f"renders pixel-identically to `{entry['same_as']}` "
310
+ f"alone — `{others}` has no effect here.")
311
+ lines.append("")
264
312
  enable = ", ".join(f"`{sheet_path_for_doc(theme, s)}`" for s in entry["sheets"])
265
313
  lines.append(f"Enable by copying {enable} into your profile's `chrome/` "
266
314
  f"folder (or with the installer flags below, if the theme "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fxcss
3
- Version: 0.16.2
3
+ Version: 0.18.0
4
4
  Summary: Live-reload, inspect and screenshot-test Firefox userChrome.css themes
5
5
  Author: AdamXweb
6
6
  License: MIT
@@ -122,7 +122,7 @@ the [releases page](https://github.com/AdamXweb/fxcss/releases) has the latest.
122
122
  CI runners' Pythons are not externally managed, so plain pip is fine there:
123
123
 
124
124
  ```bash
125
- pip install "fxcss[images]==0.16.2"
125
+ pip install "fxcss[images]==0.18.0"
126
126
  ```
127
127
 
128
128
  Either gives you an `fxcss` command. To hack on it, clone and install editable:
@@ -322,9 +322,11 @@ screenshotting a `--combo` that cannot take effect.
322
322
  > fighting over the same property. Two sheets that rearrange the same area
323
323
  > through *different* selectors — WhiteSur's `tabs-swapclose` and
324
324
  > `windows-swapclose` both move a close button and share no declarations at
325
- > all — are invisible to it. `fxcss tweaks --combo a+b` is what proves those:
326
- > if `a+b` renders identically to `b` alone, `a` was overridden. Silence here
327
- > is "nothing measurable", never "verified compatible".
325
+ > all — are invisible to it. `fxcss tweaks --combo a+b` proves those from
326
+ > the rendered pixels — and says so itself: a combo that renders identically
327
+ > to one of its parts alone is called out in TWEAKS.md as not a real
328
+ > combination on that Firefox. Silence from the static check is "nothing
329
+ > measurable", never "verified compatible"; the pixels are the judge.
328
330
 
329
331
  When the theme's default branch has moved on since its newest release, that
330
332
  choice is put to you as well — a tag can be a year behind a fix you are
@@ -369,6 +371,7 @@ Restart Firefox after either command; it reads `userChrome.css` at startup.
369
371
  fxcss upgrade # take the newest version of what you have
370
372
  fxcss upgrade --check # report only; exit code says what it found
371
373
  fxcss upgrade --audit # check the new version against your Firefox first
374
+ fxcss upgrade --compare # see it before deciding: installed vs new, rendered
372
375
  fxcss upgrade --ref v2.1.0 # somewhere specific
373
376
  fxcss rollback # …and back again
374
377
  ```
@@ -391,6 +394,15 @@ It stops rather than surprise you, in three places:
391
394
  which runs the same check as [`fxcss audit`](#fxcss-audit) against the
392
395
  fetched copy *before* anything is installed.
393
396
 
397
+ `--compare` renders what is installed and what the upgrade would install —
398
+ the profile's own `chrome/`, local edits and carried-over sheets included, not
399
+ a re-fetch of what the manifest says — and diffs them before the confirmation
400
+ prompt, printing where the before/after images landed. It completes the three
401
+ questions an upgrade can answer about a theme with no API: do the selectors
402
+ still exist (`--audit`), has the user edited anything (always checked), and
403
+ what actually changes on screen (`--compare`). Needs Pillow and a local
404
+ Firefox, like `shot`.
405
+
394
406
  `--check` changes nothing and answers with its exit code, for cron, launchd or
395
407
  CI: **0** up to date, **1** an upgrade is available, **2** it cannot be told
396
408
  (no install here, an unreachable repo, or a manifest too old to say what it
@@ -681,6 +693,16 @@ overwritten without `--force`, and the output tells you the things that
681
693
  otherwise surprise people (the comment starts after the workflows reach your
682
694
  default branch; first-time contributors need one approval click).
683
695
 
696
+ Each render captures the base branch and the pull request and diffs them. The
697
+ base half is cached: its captures are deterministic — that is the contract
698
+ fxcss's own CI asserts — so after a pull request's first push, later pushes
699
+ render only the change. The key names everything that could alter a pixel
700
+ (base commit, runner image, Firefox version, fxcss version, the workflow file
701
+ itself), and a miss on any of them re-renders rather than falling back to a
702
+ near match. GitHub scopes caches to the branch that made them, so this helps
703
+ the second push onward, not the first: a pull request that takes six pushes
704
+ to land renders the base once, not six times.
705
+
684
706
  If you'd like people to know:
685
707
 
686
708
  [![theme previews by fxcss](https://img.shields.io/badge/theme%20previews-fxcss-ff7139)](https://github.com/AdamXweb/fxcss)
@@ -699,6 +721,13 @@ renders the answer instead: the base theme, every optional stylesheet, and any
699
721
  combination you bless with `--combo`, each with a labelled **before/after crop
700
722
  of the region it actually changes** and how much of the chrome it touches.
701
723
 
724
+ A `--combo` is also judged against its own parts: if `a+b` renders
725
+ pixel-identically to `b` alone, then `a` did nothing in that combination, and
726
+ TWEAKS.md says so rather than presenting the pair as a real option. That is
727
+ the check the static conflict analysis in `install` cannot make — two sheets
728
+ can fight over the same pixels through entirely different rules — and the
729
+ rendered images settle it as a fact rather than a judgement.
730
+
702
731
  The crop is built from the changed pixels, so it needs no per-option
703
732
  configuration and cannot drift when Firefox moves something. It centres on the
704
733
  busiest *cluster* of changes rather than the bounding box of all of them,
@@ -1013,7 +1042,7 @@ check out the base revision and the pull request revision, render both, compare,
1013
1042
  and publish the result.
1014
1043
 
1015
1044
  ```yaml
1016
- - run: pip install "fxcss[images]==0.16.2" # pin: your CI, your upgrades
1045
+ - run: pip install "fxcss[images]==0.18.0" # pin: your CI, your upgrades
1017
1046
  - run: fxcss shot --theme base --out shots/base
1018
1047
  - run: fxcss shot --theme head --out shots/head
1019
1048
  - run: fxcss compare --base shots/base --head shots/head --out out/ --platform ${{ runner.os }}
@@ -2440,7 +2440,9 @@ class TemplateActionVersionTests(unittest.TestCase):
2440
2440
  two to each other turns the next Dependabot bump into a failing test.
2441
2441
  """
2442
2442
 
2443
- PIN = re.compile(r"uses:\s*actions/([a-z-]+)@v(\d+)")
2443
+ # `actions/cache/restore@v5` is actions/cache: the sub-path is not a
2444
+ # separate action and is versioned with its parent.
2445
+ PIN = re.compile(r"uses:\s*actions/([a-z-]+)(?:/[a-z-]+)?@v(\d+)")
2444
2446
 
2445
2447
  def versions(self, paths):
2446
2448
  found = {}
@@ -2473,9 +2475,75 @@ class TemplateActionVersionTests(unittest.TestCase):
2473
2475
  """
2474
2476
  root = Path(__file__).resolve().parent.parent
2475
2477
  floors = {"checkout": 5, "setup-python": 6, "upload-artifact": 6,
2476
- "download-artifact": 7, "github-script": 8}
2478
+ "download-artifact": 7, "github-script": 8, "cache": 5}
2477
2479
  shipped = self.versions((root / "fxcss" / "templates").glob("*.yml"))
2478
2480
  for name, floor in floors.items():
2479
2481
  for major in shipped.get(name, ()):
2480
2482
  self.assertGreaterEqual(
2481
2483
  major, floor, f"actions/{name}@v{major} runs on Node 20")
2484
+
2485
+
2486
+ class ComboVerdictTests(unittest.TestCase):
2487
+ """`a+b` rendering identically to `b` alone proves `a` did nothing there.
2488
+
2489
+ This is the pixel half of the sheet-conflict story: sheets.py sees two
2490
+ sheets fighting over the same declarations, and cannot see two sheets
2491
+ fighting over the same pixels through different rules (tabs-swapclose vs
2492
+ windows-swapclose share no declarations at all). The rendered images are
2493
+ the honest judge, with "identical" meaning the same thing it means in
2494
+ compare: zero pixels changed above the noise threshold.
2495
+ """
2496
+
2497
+ def image(self, colour):
2498
+ from PIL import Image
2499
+ return Image.new("RGB", (24, 16), colour)
2500
+
2501
+ def entry(self, slug):
2502
+ return {"slug": slug, "sheets": [], "percent": 0.0, "image": None}
2503
+
2504
+ def test_a_combo_identical_to_one_part_names_that_part(self):
2505
+ from fxcss.tweaks import combo_verdict
2506
+ captures = {"a": self.image((200, 50, 50)),
2507
+ "b": self.image((100, 100, 100)),
2508
+ "a+b": self.image((100, 100, 100))}
2509
+ self.assertEqual(combo_verdict(self.entry("a+b"), captures), "b")
2510
+
2511
+ def test_noise_below_the_diff_threshold_still_counts_as_identical(self):
2512
+ from fxcss.tweaks import combo_verdict
2513
+ near = self.image((100, 100, 110)) # the DiffStats noise case
2514
+ captures = {"a": self.image((200, 50, 50)),
2515
+ "b": self.image((100, 100, 100)), "a+b": near}
2516
+ self.assertEqual(combo_verdict(self.entry("a+b"), captures), "b")
2517
+
2518
+ def test_a_combo_that_is_more_than_its_parts_matches_nothing(self):
2519
+ from fxcss.tweaks import combo_verdict
2520
+ captures = {"a": self.image((200, 50, 50)),
2521
+ "b": self.image((100, 100, 100)),
2522
+ "a+b": self.image((50, 200, 50))}
2523
+ self.assertIsNone(combo_verdict(self.entry("a+b"), captures))
2524
+
2525
+ def test_single_options_are_never_judged(self):
2526
+ from fxcss.tweaks import combo_verdict
2527
+ captures = {"a": self.image((1, 2, 3)), "b": self.image((1, 2, 3))}
2528
+ self.assertIsNone(combo_verdict(self.entry("a"), captures))
2529
+
2530
+ def test_a_window_that_changed_size_is_not_compared(self):
2531
+ from PIL import Image
2532
+ from fxcss.tweaks import combo_verdict
2533
+ captures = {"a": Image.new("RGB", (10, 10), (1, 2, 3)),
2534
+ "a+b": self.image((1, 2, 3))}
2535
+ self.assertIsNone(combo_verdict(self.entry("a+b"), captures))
2536
+
2537
+ def test_the_markdown_carries_the_verdict(self):
2538
+ from fxcss.tweaks import render_markdown
2539
+ with tempfile.TemporaryDirectory() as td:
2540
+ entries = [
2541
+ {"slug": "a+b", "sheets": [], "percent": 1.0,
2542
+ "image": None, "same_as": "b"},
2543
+ {"slug": "compact-tabs", "sheets": [], "percent": 1.0,
2544
+ "image": None},
2545
+ ]
2546
+ text = render_markdown(Path(td), entries, [], "154.0")
2547
+ self.assertIn("renders pixel-identically to `b` alone", text)
2548
+ self.assertIn("`a` has no effect here", text)
2549
+ self.assertEqual(text.count("Not a real combination"), 1)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes