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**
-
-
+
+
-**Classic — original carrier**
+**Classic — original carrier with the green graph**
-
-
+
+
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