diff --git a/README.md b/README.md index 6ac4fc4..8315a66 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ pixel explosions and a spinning radar. Runs with GitHub Actions. **Lincoln — default carrier** - - A pixel jet launches from a Lincoln-inspired carrier through rolling ocean water, with an angled runway, detailed island and spinning radar. + + A pixel jet launches from a Lincoln-inspired carrier through rolling ocean water, with an angled runway, detailed island and spinning radar. -**Classic — original carrier** +**Classic — original carrier with the green graph** - - The original ivory-island carrier with a spinning radar and flowing ocean wake launches a pixel jet to clear the commit calendar. + + The original ivory-island carrier with a spinning radar and flowing ocean wake launches a pixel jet to clear a green commit calendar. Choose `carrier: 'lincoln'` (default) or `carrier: 'classic'` in your workflow. @@ -26,6 +26,11 @@ Choose `water: 'ocean'` (default) for the flowing ocean surface and wake, or `water: 'original'` for the original small wave patches. This works with either carrier. For the CLI, use `--water ocean` or `--water original`. +Choose `graph-theme: 'github'` for GitHub green activity cells, as shown +in the Classic demo. The default is `graph-theme: 'blue'`, shown in the Lincoln +demo. Either graph theme works with either carrier and water style. +For the CLI, use `--graph-theme github` or `--graph-theme blue`. + Both demos show **three jets** and **56 synthetic commits across 14 active days**. Live mode uses your actual data. **One attack clears one day cell**, regardless of that day's count. The jet returns to land, the calendar rebuilds, and the animation loops. diff --git a/action.yml b/action.yml index 9f49bf4..de04f04 100644 --- a/action.yml +++ b/action.yml @@ -32,6 +32,9 @@ inputs: water: description: Water artwork, ocean (default) or original default: ocean + graph-theme: + description: Calendar colors, blue (default) or github for GitHub green + default: blue auto-compress: description: Automatically lower scale then FPS to fit GIF limits without skipping day cells default: 'true' @@ -72,6 +75,7 @@ runs: JET_JETS: ${{ inputs.jets }} JET_CARRIER: ${{ inputs.carrier }} JET_WATER: ${{ inputs.water }} + JET_GRAPH_THEME: ${{ inputs.graph-theme }} JET_AUTO_COMPRESS: ${{ inputs.auto-compress }} JET_DEMO: ${{ inputs.demo }} JET_SEED: ${{ inputs.seed }} diff --git a/examples/demo-classic/git-jet.gif b/examples/demo-classic/git-jet.gif index da18bd6..2198b35 100644 Binary files a/examples/demo-classic/git-jet.gif and b/examples/demo-classic/git-jet.gif differ diff --git a/examples/demo-classic/git-jet.png b/examples/demo-classic/git-jet.png index 45df11a..3d73ba2 100644 Binary files a/examples/demo-classic/git-jet.png and b/examples/demo-classic/git-jet.png differ diff --git a/examples/demo-classic/report.json b/examples/demo-classic/report.json index f0360b3..83d201e 100644 --- a/examples/demo-classic/report.json +++ b/examples/demo-classic/report.json @@ -12,6 +12,7 @@ "jets": 3, "carrier": "classic", "water": "ocean", + "graph_theme": "github", "seed": "git-jet-loop", "flights": [ { @@ -63,7 +64,7 @@ "duration_seconds": 32.0, "transparent": true, "decoded_frames_match": true, - "gif_bytes": 16526574, + "gif_bytes": 16553060, "targets": [ { "date": "2026-10-18", diff --git a/examples/demo/git-jet.gif b/examples/demo/git-jet.gif index 49346b3..53eaa88 100644 Binary files a/examples/demo/git-jet.gif and b/examples/demo/git-jet.gif differ diff --git a/examples/demo/report.json b/examples/demo/report.json index 6a6387e..53ae818 100644 --- a/examples/demo/report.json +++ b/examples/demo/report.json @@ -12,6 +12,7 @@ "jets": 3, "carrier": "lincoln", "water": "ocean", + "graph_theme": "blue", "seed": "git-jet-loop", "flights": [ { diff --git a/examples/profile-workflow.yml b/examples/profile-workflow.yml index 367dcce..4af6c35 100644 --- a/examples/profile-workflow.yml +++ b/examples/profile-workflow.yml @@ -24,6 +24,7 @@ jobs: days: '365' carrier: lincoln # Choose lincoln (default) or classic. water: ocean # Choose ocean (default) or original for the old water. + graph-theme: blue # Choose blue (default) or github for green activity cells. jets: '1' # Choose 1, 2 or 3; more jets shorten busy calendars. - name: Publish README images env: diff --git a/scripts/render_demos.py b/scripts/render_demos.py index 994404f..17cba80 100644 --- a/scripts/render_demos.py +++ b/scripts/render_demos.py @@ -9,13 +9,17 @@ def main(): root = Path(__file__).resolve().parents[1] calendar = Calendar.read(root / "examples/demo/calendar.json") - for carrier, folder in (("lincoln", "demo"), ("classic", "demo-classic")): + for carrier, folder, graph_theme in ( + ("lincoln", "demo", "blue"), + ("classic", "demo-classic", "github"), + ): report = render_assets( calendar, root / "examples" / folder, Options(), jets=3, carrier=carrier, + graph_theme=graph_theme, seed="git-jet-loop", source="demo", ) diff --git a/scripts/run_action.py b/scripts/run_action.py index ce56d6f..f3359b1 100644 --- a/scripts/run_action.py +++ b/scripts/run_action.py @@ -36,6 +36,8 @@ def run(): os.environ.get("JET_CARRIER", "lincoln"), "--water", os.environ.get("JET_WATER", "ocean"), + "--graph-theme", + os.environ.get("JET_GRAPH_THEME", "blue"), ] if demo == "true": args += ["--demo", "--days", os.environ.get("JET_DAYS", "365")] diff --git a/src/git_jet/art.py b/src/git_jet/art.py index 99f4e96..37d3d52 100644 --- a/src/git_jet/art.py +++ b/src/git_jet/art.py @@ -16,10 +16,19 @@ DEEP = "#39799c" FOAM = "#f1f4e8" GRID = ("#244252", "#28556d", "#39799c", "#70b3ce", "#bfe6eb") +GITHUB_GRID = ("#30363d", "#0e4429", "#006d32", "#26a641", "#39d353") +DEFAULT_GRAPH_THEME = "blue" +GRAPH_THEMES = ("blue", "github") DEFAULT_CARRIER = "lincoln" CARRIERS = ("lincoln", "classic") +def validate_graph_theme(graph_theme): + if not isinstance(graph_theme, str) or graph_theme not in GRAPH_THEMES: + raise ValueError("graph theme must be blue or github") + return graph_theme + + def validate_carrier(carrier): if not isinstance(carrier, str) or carrier not in CARRIERS: raise ValueError("carrier must be lincoln or classic") @@ -340,10 +349,18 @@ def block(u, v, w, depth, z, height): return image -def commit_tile(level, destroyed=False): +def commit_tile(level, destroyed=False, *, theme=DEFAULT_GRAPH_THEME): + validate_graph_theme(theme) image = Image.new("RGBA", (7, 7)) draw = ImageDraw.Draw(image) outline = [(1, 0), (5, 0), (6, 1), (6, 5), (5, 6), (1, 6), (0, 5), (0, 1)] + if theme == "github": + # Flat green activity squares with neutral, transparent empty cells. + active = level and not destroyed + draw.polygon(outline, fill=GITHUB_GRID[min(4, level)] if active else GITHUB_GRID[0]) + if not active: + draw.rectangle((1, 1, 5, 5), fill=(0, 0, 0, 0)) + return image draw.polygon(outline, fill=GRID[0]) if level and not destroyed: draw.rectangle((1, 1, 5, 5), fill=GRID[min(4, level)]) diff --git a/src/git_jet/cli.py b/src/git_jet/cli.py index f933090..98c2cd1 100644 --- a/src/git_jet/cli.py +++ b/src/git_jet/cli.py @@ -7,7 +7,7 @@ from datetime import date from pathlib import Path -from .art import CARRIERS, DEFAULT_CARRIER +from .art import CARRIERS, DEFAULT_CARRIER, DEFAULT_GRAPH_THEME, GRAPH_THEMES from .calendar import Calendar from .demo import demo_calendar from .export import Options, render_assets @@ -44,6 +44,12 @@ def main(argv=None): default=DEFAULT_WATER, help="water artwork: ocean or original (default: ocean)", ) + render.add_argument( + "--graph-theme", + choices=GRAPH_THEMES, + default=DEFAULT_GRAPH_THEME, + help="calendar colors: blue (default) or github (green)", + ) render.add_argument( "--jets", type=int, choices=(1, 2, 3), default=1, help="simultaneous jets (default: 1)" ) @@ -86,6 +92,7 @@ def main(argv=None): jets=args.jets, carrier=args.carrier, water=args.water, + graph_theme=args.graph_theme, seed=args.seed, source=source_name, auto_compress=args.auto_compress, diff --git a/src/git_jet/export.py b/src/git_jet/export.py index 4d8155c..ae3f888 100644 --- a/src/git_jet/export.py +++ b/src/git_jet/export.py @@ -39,7 +39,7 @@ def __post_init__(self): def palette(): samples = [ *(art.carrier_sprite(carrier=carrier) for carrier in art.CARRIERS), - *(art.commit_tile(i) for i in range(5)), + *(art.commit_tile(i, theme=theme) for theme in art.GRAPH_THEMES for i in range(5)), *(art.explosion_sprite(i / 10) for i in range(10)), *(art.jet_sprite(0, thrust, bank) for thrust in range(3) for bank in (-1, 0, 1)), ] @@ -139,6 +139,7 @@ def export( "jets": renderer.jet_count, "carrier": renderer.carrier, "water": renderer.water, + "graph_theme": renderer.graph_theme, "seed": renderer.flight.seed, "flights": [ { @@ -190,6 +191,7 @@ def render_assets( jets=1, carrier=art.DEFAULT_CARRIER, water=DEFAULT_WATER, + graph_theme=art.DEFAULT_GRAPH_THEME, seed=None, source="live", auto_compress=True, @@ -203,7 +205,13 @@ def render_assets( for fps in rates: try: renderer = Renderer( - calendar, fps=fps, seed=seed, jets=jets, carrier=carrier, water=water + calendar, + fps=fps, + seed=seed, + jets=jets, + carrier=carrier, + water=water, + graph_theme=graph_theme, ) scales = ( range(options.scale, 0, -1) diff --git a/src/git_jet/render.py b/src/git_jet/render.py index 9e7cba9..f1dca80 100644 --- a/src/git_jet/render.py +++ b/src/git_jet/render.py @@ -7,12 +7,14 @@ from .art import ( CREAM, DEFAULT_CARRIER, + DEFAULT_GRAPH_THEME, GOLD, carrier_sprite, commit_tile, explosion_sprite, jet_sprite, validate_carrier, + validate_graph_theme, ) from .calendar import Calendar from .flight import ( @@ -39,12 +41,14 @@ def __init__( jets=1, carrier=DEFAULT_CARRIER, water=DEFAULT_WATER, + graph_theme=DEFAULT_GRAPH_THEME, ): if type(jets) is not int or not 1 <= jets <= 3: raise ValueError("jets must be an integer in 1..3") self.jet_count = jets self.carrier = validate_carrier(carrier) self.water = validate_water(water) + self.graph_theme = validate_graph_theme(graph_theme) self.calendar = calendar self.cells = calendar.cells() self.flight = ( @@ -74,7 +78,7 @@ def grid_frame(self, frame_number: int): hit = self.hit_by_date.get(cell.day.date) if cell.day else None cleared = hit is not None and frame >= hit and not self.flight.restored(cell, frame) image.alpha_composite( - commit_tile(level, cleared), + commit_tile(level, cleared, theme=self.graph_theme), (GRID_X + cell.column * GRID_STEP, GRID_Y + cell.row * GRID_STEP), ) return image diff --git a/tests/test_action.py b/tests/test_action.py index d12eecc..482f56c 100644 --- a/tests/test_action.py +++ b/tests/test_action.py @@ -21,6 +21,7 @@ def test_action_forwards_inputs_and_writes_only_successful_output(monkeypatch, t monkeypatch.setenv("JET_JETS", "3") monkeypatch.setenv("JET_CARRIER", "classic") monkeypatch.setenv("JET_WATER", "original") + monkeypatch.setenv("JET_GRAPH_THEME", "github") monkeypatch.setenv("JET_AUTO_COMPRESS", "false") monkeypatch.setenv("JET_OUTPUT_DIR", "generated") monkeypatch.setenv("JET_SEED", "reproducible-action-route") @@ -38,6 +39,7 @@ def collect(argv): assert args[args.index("--jets") + 1] == "3" assert args[args.index("--carrier") + 1] == "classic" assert args[args.index("--water") + 1] == "original" + assert args[args.index("--graph-theme") + 1] == "github" assert "--no-auto-compress" in args assert args[args.index("--seed") + 1] == "reproducible-action-route" outputs = dict(line.split("=", 1) for line in (tmp_path / "outputs").read_text().splitlines()) @@ -91,6 +93,7 @@ def test_actual_action_script_generates_demo_assets_and_returns_paths(tmp_path, "JET_JETS": "3", "JET_CARRIER": carrier, "JET_WATER": water, + "JET_GRAPH_THEME": "github", "JET_AUTO_COMPRESS": "true", "GITHUB_TOKEN": "never-save-this-test-token", }, @@ -104,6 +107,7 @@ def test_actual_action_script_generates_demo_assets_and_returns_paths(tmp_path, assert report["jets"] == 3 and len(report["flights"]) == 3 assert report["carrier"] == carrier assert report["water"] == water + assert report["graph_theme"] == "github" assert sum(f["attacks"] for f in report["flights"]) == report["attacks"] assert report["decoded_frames_match"] outputs = dict(line.split("=", 1) for line in (tmp_path / "outputs").read_text().splitlines()) diff --git a/tests/test_auto_compress.py b/tests/test_auto_compress.py index 3ca889d..35dc7ac 100644 --- a/tests/test_auto_compress.py +++ b/tests/test_auto_compress.py @@ -98,6 +98,7 @@ def fit(renderer, output, options, **kwargs): assert renderer.flight.seed == "keep-this-route" and renderer.jet_count == 3 assert renderer.carrier == "classic" assert renderer.water == "original" + assert renderer.graph_theme == "github" assert len(renderer.flight.strikes) == 14 assert sum(s.cell.day.commits for s in renderer.flight.strikes) == calendar.total if options.fps == 20: @@ -107,7 +108,13 @@ def fit(renderer, output, options, **kwargs): monkeypatch.setattr("git_jet.flight.secrets.token_hex", fresh_seed) monkeypatch.setattr(exporter, "export", fit) assert render_assets( - calendar, tmp_path, Options(), jets=3, carrier="classic", water="original" + calendar, + tmp_path, + Options(), + jets=3, + carrier="classic", + water="original", + graph_theme="github", ) == {"fps": 10} assert attempts == [Options(2, 20), Options(1, 20), Options(1, 10)] assert len(seeds) == 1 diff --git a/tests/test_export.py b/tests/test_export.py index de377da..0305e57 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -55,6 +55,7 @@ def forbidden(*args, **kwargs): assert report["jets"] == jets and len(report["flights"]) == jets assert report["carrier"] == carrier assert report["water"] == water + assert report["graph_theme"] == "blue" assert sum(f["attacks"] for f in report["flights"]) == 2 assert {t["jet"] for t in report["targets"]}.issubset(set(range(1, jets + 1))) renderer = Renderer(calendar, jets=jets, seed=report["seed"], carrier=carrier, water=water) diff --git a/tests/test_graph_theme.py b/tests/test_graph_theme.py new file mode 100644 index 0000000..60328b8 --- /dev/null +++ b/tests/test_graph_theme.py @@ -0,0 +1,71 @@ +import pytest +from PIL import ImageColor + +from git_jet.art import GITHUB_GRID, commit_tile +from git_jet.cli import main +from git_jet.demo import demo_calendar +from git_jet.flight import GRID_Y +from git_jet.render import Renderer + + +def test_blue_remains_the_default_for_tiles_and_scenes(): + for level in range(5): + for destroyed in (False, True): + assert ( + commit_tile(level, destroyed).tobytes() + == commit_tile(level, destroyed, theme="blue").tobytes() + ) + default = Renderer(demo_calendar(), seed="graph-colors") + blue = Renderer(demo_calendar(), seed="graph-colors", graph_theme="blue") + assert default.graph_theme == "blue" + assert default.frame(0).tobytes() == blue.frame(0).tobytes() + + +@pytest.mark.parametrize("level", (1, 2, 3, 4)) +def test_github_theme_uses_flat_green_activity_levels(level): + tile = commit_tile(level, theme="github") + green = ImageColor.getcolor(GITHUB_GRID[level], "RGBA") + assert tile.getpixel((3, 3)) == green + assert {color for _, color in tile.getcolors() if color[3]} == {green} + + +def test_github_destroyed_days_become_transparent_empty_cells(): + empty = commit_tile(0, theme="github") + assert empty.getpixel((3, 3))[3] == 0 + for level in range(1, 5): + assert commit_tile(level, True, theme="github").tobytes() == empty.tobytes() + + +def test_graph_theme_preserves_flight_targets_clearing_and_scene_geometry(): + blue = Renderer(demo_calendar(), seed="graph-colors", graph_theme="blue") + green = Renderer(demo_calendar(), seed="graph-colors", graph_theme="github") + assert blue.flight.strikes == green.flight.strikes + assert blue.frame_count == green.frame_count + frames = {0, blue.frame_count - 1, *(s.hit for s in blue.flight.strikes)} + for frame in frames: + assert ( + blue.grid_frame(frame).getchannel("A").tobytes() + == green.grid_frame(frame).getchannel("A").tobytes() + ) + assert ( + blue.frame(0).crop((0, 0, 480, GRID_Y)).tobytes() + == green.frame(0).crop((0, 0, 480, GRID_Y)).tobytes() + ) + assert blue.grid_frame(0).tobytes() != green.grid_frame(0).tobytes() + assert green.grid_frame(green.frame_count - 1).tobytes() == green.grid_frame(0).tobytes() + + +@pytest.mark.parametrize("theme", ("unknown", None, [], True)) +def test_invalid_graph_themes_are_rejected(theme): + with pytest.raises(ValueError, match="graph theme"): + Renderer(demo_calendar(), graph_theme=theme) + + +def test_cli_rejects_unknown_graph_theme_before_collecting(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("invalid graph theme must not collect data") + + monkeypatch.setattr("git_jet.cli.GitHubClient.collect", forbidden) + with pytest.raises(SystemExit) as error: + main(["render", "--username", "octocat", "--graph-theme", "unknown"]) + assert error.value.code == 2