diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 0794a2a1..55cdc468 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -179,6 +179,14 @@ jobs:
print(f"supporters: {len(supporters)}")
PY
+ # Publish an OpenCADStudio-branded star-history chart for the README.
+ # The timestamp media type returns when each current stargazer starred
+ # the repository; the script follows every API page and emits both themes.
+ - name: Generate star history charts
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ run: python3 scripts/generate-star-history.py --output-dir dist
+
# Browsers cannot reliably fetch YouTube playlist/oEmbed responses because
# of CORS. Build a same-origin listing and thumbnail directory for the
# Start page. Keep the checked-in snapshot if YouTube is temporarily
diff --git a/README.md b/README.md
index 77efd676..ae71fb7c 100644
--- a/README.md
+++ b/README.md
@@ -158,11 +158,11 @@ cargo build --release --bin OpenCADStudio
```
## Star History
-
+
-
-
-
+
+
+
diff --git a/scripts/generate-star-history.py b/scripts/generate-star-history.py
new file mode 100644
index 00000000..e4f523de
--- /dev/null
+++ b/scripts/generate-star-history.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""Generate OpenCADStudio-branded GitHub star-history SVG charts."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import re
+import urllib.request
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from xml.sax.saxutils import escape
+
+
+API_VERSION = "2026-03-10"
+WIDTH = 960
+HEIGHT = 380
+PLOT_LEFT = 72
+PLOT_RIGHT = 928
+PLOT_TOP = 82
+PLOT_BOTTOM = 326
+
+
+THEMES = {
+ "light": {
+ "background": "#ffffff",
+ "border": "#dbe3ef",
+ "grid": "#dbe3ef",
+ "text": "#0f172a",
+ "muted": "#64748b",
+ "line": "#0284c7",
+ "area": "#38bdf8",
+ "point": "#0369a1",
+ },
+ "dark": {
+ "background": "#0b1220",
+ "border": "#263449",
+ "grid": "#263449",
+ "text": "#e5edf7",
+ "muted": "#94a3b8",
+ "line": "#38bdf8",
+ "area": "#0ea5e9",
+ "point": "#7dd3fc",
+ },
+}
+
+
+def next_link(header: str | None) -> str | None:
+ if not header:
+ return None
+ for item in header.split(","):
+ match = re.match(r'\s*<([^>]+)>;\s*rel="([^"]+)"', item)
+ if match and match.group(2) == "next":
+ return match.group(1)
+ return None
+
+
+def fetch_star_dates(repository: str, token: str | None) -> list[datetime]:
+ url = f"https://api.github.com/repos/{repository}/stargazers?per_page=100"
+ headers = {
+ "Accept": "application/vnd.github.star+json",
+ "User-Agent": "OpenCADStudio-star-history",
+ "X-GitHub-Api-Version": API_VERSION,
+ }
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ dates: list[datetime] = []
+ for _ in range(100):
+ request = urllib.request.Request(url, headers=headers)
+ with urllib.request.urlopen(request, timeout=30) as response:
+ payload = json.load(response)
+ link = response.headers.get("Link")
+ for entry in payload:
+ value = entry.get("starred_at")
+ if value:
+ dates.append(datetime.fromisoformat(value.replace("Z", "+00:00")))
+ url = next_link(link)
+ if not url:
+ break
+ else:
+ raise RuntimeError("stargazer pagination exceeded 100 pages")
+
+ if not dates:
+ raise RuntimeError("GitHub returned no dated stargazers")
+ dates.sort()
+ return dates
+
+
+def nice_axis(stars: int) -> tuple[int, int]:
+ rough_step = max(stars / 5, 1)
+ magnitude = 10 ** math.floor(math.log10(rough_step))
+ step = magnitude
+ for multiplier in (1, 2, 5, 10):
+ candidate = multiplier * magnitude
+ if candidate >= rough_step:
+ step = candidate
+ break
+ top = math.ceil(stars / step) * step
+ if top <= stars:
+ top += step
+ return int(top), int(step)
+
+
+def render_svg(repository: str, dates: list[datetime], theme: str) -> str:
+ colors = THEMES[theme]
+ now = datetime.now(timezone.utc)
+ start = dates[0] - timedelta(days=2)
+ end = max(now, dates[-1] + timedelta(days=2))
+ duration = max((end - start).total_seconds(), 1)
+ plot_width = PLOT_RIGHT - PLOT_LEFT
+ plot_height = PLOT_BOTTOM - PLOT_TOP
+ y_top, y_step = nice_axis(len(dates))
+
+ def x_position(moment: datetime) -> float:
+ ratio = (moment - start).total_seconds() / duration
+ return PLOT_LEFT + ratio * plot_width
+
+ def y_position(stars: int) -> float:
+ return PLOT_BOTTOM - (stars / y_top) * plot_height
+
+ line_parts = [f"M {PLOT_LEFT:.1f} {PLOT_BOTTOM:.1f}"]
+ for count, moment in enumerate(dates, 1):
+ line_parts.append(f"H {x_position(moment):.1f} V {y_position(count):.1f}")
+ line_path = " ".join(line_parts)
+ area_path = f"{line_path} V {PLOT_BOTTOM:.1f} H {PLOT_LEFT:.1f} Z"
+
+ grid = []
+ for value in range(0, y_top + 1, y_step):
+ y = y_position(value)
+ grid.append(
+ f''
+ )
+ grid.append(
+ f'{value}'
+ )
+
+ x_labels = []
+ for index in range(5):
+ moment = start + (end - start) * (index / 4)
+ x = x_position(moment)
+ if index in (0, 4):
+ label = moment.strftime("%b %Y")
+ else:
+ label = moment.strftime("%b")
+ x_labels.append(
+ f''
+ )
+ x_labels.append(
+ f'{escape(label)}'
+ )
+
+ title = f"{repository.split('/')[-1]} Star History"
+ subtitle = f"{len(dates)} GitHub stars ยท Updated {now:%d %b %Y}"
+ last_x = x_position(dates[-1])
+ last_y = y_position(len(dates))
+ label_y = max(PLOT_TOP + 16, last_y - 14)
+
+ return f'''
+'''
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--repository",
+ default=os.environ.get("GITHUB_REPOSITORY", "HakanSeven12/OpenCADStudio"),
+ )
+ parser.add_argument("--output-dir", type=Path, default=Path("dist"))
+ args = parser.parse_args()
+
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+ dates = fetch_star_dates(args.repository, token)
+ args.output_dir.mkdir(parents=True, exist_ok=True)
+ for theme in THEMES:
+ output = args.output_dir / f"star-history-{theme}.svg"
+ output.write_text(render_svg(args.repository, dates, theme), encoding="utf-8")
+ print(f"star history: {len(dates)} stars")
+
+
+if __name__ == "__main__":
+ main()