diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b03010..602e9db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to GitPulse are documented here. The format is based on ## [Unreleased] +### Changed + +- The HTML dashboard and terminal summary use the Full-Stack-Spectrum brand: + near-black surfaces with a dot grid, Space Grotesk and JetBrains Mono, and + the five-colour spectrum (blue, blue-soft, yellow, orange, red) for charts. +- The dashboard is dark only; the light theme and its toggle are gone. +- The language breakdown shows the top five languages plus Other, down from + seven, so each gets a spectrum colour. +- The dashboard loads its two fonts from Google Fonts and falls back to system + fonts offline. + ## [0.2.1] - 2026-11-03 ### Security diff --git a/README.md b/README.md index dc8d666..c8472b5 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,22 @@ # GitPulse -[![CI](https://img.shields.io/github/actions/workflow/status/fullstackspectrum/gitpulse/ci.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&label=CI)](https://github.com/fullstackspectrum/gitpulse/actions/workflows/ci.yml) -[![PyPI](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpypi.org%2Fpypi%2Fgitpulse-analytics%2Fjson&query=%24.info.version&prefix=v&label=pypi&style=for-the-badge&logo=pypi&logoColor=white&color=3775A9)](https://pypi.org/project/gitpulse-analytics/) -[![Python](https://img.shields.io/pypi/pyversions/gitpulse-analytics?style=for-the-badge&logo=python&logoColor=white&color=3776AB)](https://pypi.org/project/gitpulse-analytics/) -[![Homebrew](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffullstackspectrum%2Fhomebrew-gitpulse%2Fmain%2FFormula%2Fgitpulse.rb&search=gitpulse_analytics-%28%5B0-9.%5D%2B%29%5C.tar%5C.gz&replace=v%241&label=homebrew&style=for-the-badge&logo=homebrew&logoColor=white&color=FBB040)](https://github.com/fullstackspectrum/homebrew-gitpulse) -[![Docker](https://img.shields.io/docker/image-size/fullstackspectrum/gitpulse/latest?style=for-the-badge&logo=docker&logoColor=white&label=docker&color=2496ED)](https://hub.docker.com/r/fullstackspectrum/gitpulse) -[![License](https://img.shields.io/github/license/fullstackspectrum/gitpulse?style=for-the-badge&color=6E7781)](LICENSE) +[![CI](https://img.shields.io/github/actions/workflow/status/fullstackspectrum/gitpulse/ci.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&label=CI&color=4f8cff&labelColor=10151d)](https://github.com/fullstackspectrum/gitpulse/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpypi.org%2Fpypi%2Fgitpulse-analytics%2Fjson&query=%24.info.version&prefix=v&label=pypi&style=for-the-badge&logo=pypi&logoColor=white&color=8fb4ff&labelColor=10151d)](https://pypi.org/project/gitpulse-analytics/) +[![Python](https://img.shields.io/pypi/pyversions/gitpulse-analytics?style=for-the-badge&logo=python&logoColor=white&color=f5c84c&labelColor=10151d)](https://pypi.org/project/gitpulse-analytics/) +[![Homebrew](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffullstackspectrum%2Fhomebrew-gitpulse%2Fmain%2FFormula%2Fgitpulse.rb&search=gitpulse_analytics-%28%5B0-9.%5D%2B%29%5C.tar%5C.gz&replace=v%241&label=homebrew&style=for-the-badge&logo=homebrew&logoColor=white&color=ff8a3d&labelColor=10151d)](https://github.com/fullstackspectrum/homebrew-gitpulse) +[![Docker](https://img.shields.io/docker/image-size/fullstackspectrum/gitpulse/latest?style=for-the-badge&logo=docker&logoColor=white&label=docker&color=ff4d5e&labelColor=10151d)](https://hub.docker.com/r/fullstackspectrum/gitpulse) +[![License](https://img.shields.io/github/license/fullstackspectrum/gitpulse?style=for-the-badge&color=646f7f&labelColor=10151d)](LICENSE)
-[![PyPI downloads](https://img.shields.io/pepy/dt/gitpulse-analytics?style=for-the-badge&logo=pypi&logoColor=white&label=pypi%20downloads&color=3775A9)](https://pepy.tech/projects/gitpulse-analytics) -[![Docker pulls](https://img.shields.io/docker/pulls/fullstackspectrum/gitpulse?style=for-the-badge&logo=docker&logoColor=white&label=docker%20pulls&color=2496ED)](https://hub.docker.com/r/fullstackspectrum/gitpulse) +[![PyPI downloads](https://img.shields.io/pepy/dt/gitpulse-analytics?style=for-the-badge&logo=pypi&logoColor=white&label=pypi%20downloads&color=8fb4ff&labelColor=10151d)](https://pepy.tech/projects/gitpulse-analytics) +[![Docker pulls](https://img.shields.io/docker/pulls/fullstackspectrum/gitpulse?style=for-the-badge&logo=docker&logoColor=white&label=docker%20pulls&color=ff4d5e&labelColor=10151d)](https://hub.docker.com/r/fullstackspectrum/gitpulse) A local Python CLI for GitHub repository traffic and release asset download stats. GitPulse can build a dashboard for a whole organization, and it keeps SQLite snapshots so you have history beyond GitHub's short traffic window. +Part of [Full-Stack-Spectrum](https://fullstackspectrum.com) ↗: small, sharp, open-source utilities that each do one job and install in one line. + > **What GitHub can tell you:** aggregate daily views and clones, unique counts, popular paths, referrers, and release-asset download totals. It does **not** identify individual visitors or people who cloned a repository. GitHub's traffic data is generally available for the most recent 14 days. Release asset downloads are not Homebrew formula install counts. -![GitPulse organization dashboard](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard-light.png) +![GitPulse organization dashboard](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard.png) ## Requirements @@ -98,9 +100,7 @@ It also writes a self-contained HTML dashboard to `gitpulse-ORG.html` in the cur - The most viewed and most starred repositories, a language breakdown, top referrers, and release downloads by repository - A sortable, filterable table of every repository with a 14-day views sparkline -Styles, charts, and scripts are all inline, so it works offline (only the owner's avatar loads from GitHub). It follows your system's light or dark theme, and has a toggle to switch. - -![Dashboard in dark mode](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard-dark.png) +Styles, charts, and scripts are all inline. Only the owner's avatar and the Space Grotesk and JetBrains Mono web fonts load from the network; offline, the dashboard falls back to system fonts. It uses the dark [Full-Stack-Spectrum](https://fullstackspectrum.com) style. By default, forks and archived repositories are skipped; `--all` includes them. When your token can't read traffic for a repository, its stars, forks, and downloads still appear, and the dashboard lists which repositories are missing traffic and why. Unique visitor and cloner counts are summed per repository, so one person who visits two repositories is counted twice. diff --git a/docs/screenshots/dashboard-dark.png b/docs/screenshots/dashboard-dark.png deleted file mode 100644 index 5699db3..0000000 Binary files a/docs/screenshots/dashboard-dark.png and /dev/null differ diff --git a/docs/screenshots/dashboard-light.png b/docs/screenshots/dashboard-light.png deleted file mode 100644 index ce1ae59..0000000 Binary files a/docs/screenshots/dashboard-light.png and /dev/null differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..0c20057 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/terminal.png b/docs/screenshots/terminal.png index 38dd65a..adfce20 100644 Binary files a/docs/screenshots/terminal.png and b/docs/screenshots/terminal.png differ diff --git a/src/gitpulse/cli.py b/src/gitpulse/cli.py index f694400..0878cba 100644 --- a/src/gitpulse/cli.py +++ b/src/gitpulse/cli.py @@ -19,6 +19,7 @@ TimeElapsedColumn, ) from rich.table import Table +from rich.theme import Theme from gitpulse import __version__ from gitpulse.github_api import GitHubAPIError, GitHubClient @@ -31,7 +32,26 @@ help="Local GitHub repository traffic and release analytics.", no_args_is_help=True, ) -console = Console() +# Full-Stack-Spectrum palette (fullstackspectrum.com): red leads, the rest of the spectrum marks data. +THEME = Theme( + { + "brand": "bold #ff4d5e", + "heading": "bold #8fb4ff", + "frame": "#2f3a4b", + "views": "#4f8cff", + "clones": "#ff8a3d", + "stars": "#f5c84c", + "downloads": "#8fb4ff", + "ok": "#8fb4ff", + "warn": "#f5c84c", + "error": "bold #ff4d5e", + "progress.spinner": "#ff4d5e", + "bar.complete": "#ff4d5e", + "bar.finished": "#4f8cff", + "bar.pulse": "#ff4d5e", + } +) +console = Console(theme=THEME) def _version_callback(value: bool) -> None: @@ -53,7 +73,7 @@ def _client() -> GitHubClient: try: return GitHubClient() except GitHubAPIError as exc: - console.print(f"[red]Error:[/red] {exc}") + console.print(f"[error]Error:[/error] {exc}") raise typer.Exit(code=1) from exc @@ -61,7 +81,7 @@ def _run(action: Any) -> Any: try: return action() except GitHubAPIError as exc: - console.print(f"[red]Error:[/red] {exc}") + console.print(f"[error]Error:[/error] {exc}") raise typer.Exit(code=1) from exc @@ -73,7 +93,7 @@ def _repo_parts(repository: str) -> tuple[str, str]: def _traffic_table(owner: str, repo: str, metric: str, data: dict[str, Any]) -> Table: - table = Table(title=f"{owner}/{repo} · {metric}", header_style="bold cyan") + table = Table(title=f"{owner}/{repo} · {metric}", header_style="heading") if metric in {"views", "clones"}: table.add_column("Date") table.add_column("Count", justify="right") @@ -110,7 +130,7 @@ def list_repos( repos = _run(_client().list_repositories) if not all_repos: repos = [repo for repo in repos if not repo.get("fork") and not repo.get("archived")] - table = Table(title="Your repositories", header_style="bold cyan") + table = Table(title="Your repositories", header_style="heading") table.add_column("Repository") table.add_column("Visibility") table.add_column("Stars", justify="right") @@ -143,7 +163,7 @@ def traffic( console.print(_traffic_table(owner, repo, metric, data)) if save and metric in {"views", "clones"}: SnapshotStore().save(owner, repo, metric, data) - console.print("[green]Snapshot saved locally.[/green]") + console.print("[ok]Snapshot saved locally.[/ok]") console.print("[dim]GitHub provides aggregate traffic data, not visitor identities; traffic history is typically limited to 14 days.[/dim]") @@ -159,7 +179,7 @@ def snapshot( data = _run(lambda metric=metric: client.traffic(owner, repo, metric)) store.save(owner, repo, metric, data) console.print(_traffic_table(owner, repo, metric, data)) - console.print("[green]Snapshots saved. Run this regularly to build longer-term history.[/green]") + console.print("[ok]Snapshots saved. Run this regularly to build longer-term history.[/ok]") @app.command() @@ -173,7 +193,7 @@ def history( if metric not in {"views", "clones"}: raise typer.BadParameter("History is available for views or clones") rows = SnapshotStore().history(owner, repo, metric, limit) - table = Table(title=f"Local history · {owner}/{repo} · {metric}", header_style="bold cyan") + table = Table(title=f"Local history · {owner}/{repo} · {metric}", header_style="heading") table.add_column("Collected at") table.add_column("14-day total", justify="right") table.add_column("Sum of daily uniques*", justify="right") @@ -199,7 +219,7 @@ def release_downloads( """Show GitHub release asset download counts (not Homebrew install counts).""" owner, repo = _repo_parts(repository) release_count, assets = _run(lambda: _client().release_downloads(owner, repo)) - table = Table(title=f"Release asset downloads · {owner}/{repo}", header_style="bold cyan") + table = Table(title=f"Release asset downloads · {owner}/{repo}", header_style="heading") table.add_column("Release") table.add_column("Asset") table.add_column("Downloads", justify="right") @@ -237,22 +257,24 @@ def _org_summary(report: OrgReport) -> None: name = report.profile.get("name") or report.owner console.print( Panel.fit( + f"[dim]//[/dim] [brand]gitpulse[/brand]\n" f"[bold]{name}[/bold] [dim]@{report.owner}[/dim]\n" f"{len(report.repos)} repositories · traffic {window}", - border_style="cyan", + border_style="frame", ) ) - def tile(label: str, value: int, style: str = "bold") -> Panel: - return Panel(f"[{style}]{value:,}[/{style}]\n[dim]{label}[/dim]", expand=True, border_style="dim") + def tile(label: str, value: int, style: str = "") -> Panel: + number = f"[{style}]{value:,}[/{style}]" if style else f"{value:,}" + return Panel(f"[bold]{number}[/bold]\n[dim]{label}[/dim]", expand=True, border_style="frame") tiles = [ - tile("views · 14d", report.total("views"), "bold cyan"), + tile("views · 14d", report.total("views"), "views"), tile("unique visitors*", report.total("view_uniques")), - tile("clones · 14d", report.total("clones"), "bold magenta"), - tile("stars", report.total("stars"), "bold yellow"), + tile("clones · 14d", report.total("clones"), "clones"), + tile("stars", report.total("stars"), "stars"), tile("forks", report.total("forks")), - tile("release downloads", report.total("downloads"), "bold green"), + tile("release downloads", report.total("downloads"), "downloads"), ] grid = Table.grid(expand=True, padding=(0, 1)) for _ in range(3): @@ -261,11 +283,11 @@ def tile(label: str, value: int, style: str = "bold") -> Panel: grid.add_row(*tiles[3:]) console.print(grid) if views: - console.print(f" [dim]daily views[/dim] [cyan]{_spark([c for _, c in views])}[/cyan]") + console.print(f" [dim]daily views[/dim] [views]{_spark([c for _, c in views])}[/views]") clones = [c for _, c in report.daily("clones_daily")] - console.print(f" [dim]daily clones[/dim] [magenta]{_spark(clones)}[/magenta]\n") + console.print(f" [dim]daily clones[/dim] [clones]{_spark(clones)}[/clones]\n") - table = Table(header_style="bold cyan", box=box.SIMPLE_HEAD, expand=True) + table = Table(header_style="heading", box=box.SIMPLE_HEAD, expand=True) table.add_column("Repository", overflow="fold") table.add_column("Lang", style="dim") for column in ("Stars", "Forks", "Views", "Unique", "Clones", "Downloads"): @@ -283,13 +305,13 @@ def tile(label: str, value: int, style: str = "bold") -> Panel: f"{repo.view_uniques:,}" if ok else na, f"{repo.clones:,}" if ok else na, f"{repo.downloads:,}" if repo.releases_error is None else na, - f"[cyan]{_spark([repo.views_daily.get(d, 0) for d in days])}[/cyan]" if ok and days else "", + f"[views]{_spark([repo.views_daily.get(d, 0) for d in days])}[/views]" if ok and days else "", ) console.print(table) referrers = report.referrers(limit=5) if referrers: - ref_table = Table(title="Top referrers", header_style="bold cyan", box=box.SIMPLE_HEAD) + ref_table = Table(title="Top referrers", header_style="heading", box=box.SIMPLE_HEAD) ref_table.add_column("Source") ref_table.add_column("Views", justify="right") ref_table.add_column("Unique*", justify="right") @@ -300,7 +322,7 @@ def tile(label: str, value: int, style: str = "bold") -> Panel: missing = [repo for repo in report.repos if repo.traffic_error] if missing: console.print( - f"[yellow]Traffic unavailable for {len(missing)} of {len(report.repos)} repositories[/yellow] " + f"[warn]Traffic unavailable for {len(missing)} of {len(report.repos)} repositories[/warn] " "[dim](the token needs Administration: Read or push access). See the dashboard for details.[/dim]" ) console.print("[dim]*Unique counts are summed per repository, so one person can be counted more than once.[/dim]") @@ -354,11 +376,11 @@ def org_dashboard( for metric, payload in repo.raw_traffic.items(): store.save(repo.owner, repo.name, metric, payload) saved += 1 - console.print(f"[green]Saved snapshots for {saved} repositories.[/green]") + console.print(f"[ok]Saved snapshots for {saved} repositories.[/ok]") path = Path(output or f"gitpulse-{report.owner}.html").expanduser().resolve() path.write_text(render_html(report), encoding="utf-8") - console.print(f"\n[bold green]✓[/bold green] Dashboard written to [link={path.as_uri()}]{path}[/link]") + console.print(f"\n[ok]✓[/ok] Dashboard written to [link={path.as_uri()}]{path}[/link]") if open_browser: webbrowser.open(path.as_uri()) diff --git a/src/gitpulse/report.py b/src/gitpulse/report.py index 5a21aec..029374a 100644 --- a/src/gitpulse/report.py +++ b/src/gitpulse/report.py @@ -1,4 +1,7 @@ -"""Render an OrgReport as a self-contained HTML dashboard (no external scripts or styles).""" +"""Render an OrgReport as a self-contained HTML dashboard in the Full-Stack-Spectrum style. + +Everything is inline except the two web fonts, which fall back to system fonts offline. +""" from __future__ import annotations @@ -9,9 +12,8 @@ from gitpulse.org import OrgReport, RepoStats -# Categorical slots in fixed order; slot 4 (yellow) takes dark ink for labels inside it. -SERIES = 8 -DARK_INK_SLOTS = {4} +# Categorical slots follow the five-colour spectrum, cool to hot; a sixth slot is "Other". +SERIES = 6 def compact(value: float) -> str: @@ -157,7 +159,7 @@ def _languages(report: OrgReport) -> str: for slot, (name, count) in enumerate(shown, start=1): var = "--muted-fill" if name == "Other" else f"--series-{slot}" share = count / total * 100 - ink = " dark-ink" if slot in DARK_INK_SLOTS and name != "Other" else "" + ink = " other" if name == "Other" else "" label = f"{share:.0f}%" if share >= 9 else "" blocks.append( f' list[tuple[str, int, str, str]]: ) line = _line_chart( - [("Views", "--series-1", views_daily), ("Clones", "--series-2", clones_daily)] + [("Views", "--blue", views_daily), ("Clones", "--orange", clones_daily)] ) return TEMPLATE.format( title=escape(f"{display} · GitPulse"), @@ -288,125 +290,139 @@ def repo_bars(attr: str, unit: str) -> list[tuple[str, int, str, str]]: CSS = """ :root { - color-scheme: light; - --page: #f9f9f7; --surface: #fcfcfb; --text: #0b0b0b; --text-2: #52514e; --muted: #898781; - --grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11,11,11,0.10); --wash: rgba(11,11,11,0.04); - --accent: #2a78d6; --muted-fill: #b4b2a9; - --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --series-4: #eda100; - --series-5: #e87ba4; --series-6: #008300; --series-7: #6250d6; --series-8: #e34948; -} -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - color-scheme: dark; - --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781; - --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05); - --accent: #3987e5; --muted-fill: #5f5e5a; - --series-1: #3987e5; --series-2: #d95926; --series-3: #199e70; --series-4: #c98500; - --series-5: #d55181; --series-6: #008300; --series-7: #9085e9; --series-8: #e66767; - } -} -:root[data-theme="dark"] { color-scheme: dark; - --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781; - --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05); - --accent: #3987e5; --muted-fill: #5f5e5a; - --series-1: #3987e5; --series-2: #d95926; --series-3: #199e70; --series-4: #c98500; - --series-5: #d55181; --series-6: #008300; --series-7: #9085e9; --series-8: #e66767; + /* Full-Stack-Spectrum tokens (fullstackspectrum.com). Dark only, by design. */ + --bg: #0a0d12; --bg-elev: #10151d; --bg-card: #121821; + --line: #222b38; --line-strong: #2f3a4b; + --text: #eef1f6; --text-dim: #9aa4b2; --text-faint: #646f7f; + --blue: #4f8cff; --blue-soft: #8fb4ff; --yellow: #f5c84c; --orange: #ff8a3d; --red: #ff4d5e; + --wash: rgba(255,255,255,0.04); --muted-fill: #646f7f; + /* Categorical slots run the spectrum cool to hot. */ + --series-1: var(--blue); --series-2: var(--blue-soft); --series-3: var(--yellow); + --series-4: var(--orange); --series-5: var(--red); + --font-display: "Space Grotesk", system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; } * { box-sizing: border-box; } -body { margin: 0; background: var(--page); color: var(--text); - font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; } -a { color: inherit; text-decoration: none; } -a:hover { text-decoration: underline; } -.wrap-page { max-width: 1200px; margin: 0 auto; padding: 32px 24px 64px; } -header { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; } -.avatar { width: 56px; height: 56px; border-radius: 12px; border: 1px solid var(--border); } -h1 { font-size: 24px; margin: 0; font-weight: 650; letter-spacing: -0.01em; } -h2 { font-size: 15px; margin: 0 0 4px; font-weight: 600; } -.sub { color: var(--text-2); font-size: 13px; } -.theme-toggle { margin-left: auto; flex-shrink: 0; white-space: nowrap; background: var(--surface); color: var(--text-2); - border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; font: inherit; cursor: pointer; } -.theme-toggle:hover { background: var(--wash); } -.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 20px; } -.card-sub { color: var(--muted); font-size: 12px; margin-bottom: 16px; } +body { margin: 0; color: var(--text); background-color: var(--bg); + background-image: radial-gradient(circle at 1px 1px, rgba(255,255,255,0.07) 1px, transparent 0); + background-size: 28px 28px; + font: 15px/1.6 var(--font-display); -webkit-font-smoothing: antialiased; } +a { color: inherit; text-decoration: none; transition: color .15s; } +a:hover { color: var(--blue-soft); } +:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; } +::selection { background: var(--red); color: var(--bg); } +.wrap-page { max-width: 1160px; margin: 0 auto; padding: 48px clamp(16px, 4vw, 40px) 64px; } +.spectrum { display: flex; gap: 6px; margin-bottom: 28px; } +.spectrum span { width: 28px; height: 8px; border-radius: 1px; } +.spectrum span:nth-child(1) { background: var(--blue); } +.spectrum span:nth-child(2) { background: var(--blue-soft); } +.spectrum span:nth-child(3) { background: var(--yellow); } +.spectrum span:nth-child(4) { background: var(--orange); } +.spectrum span:nth-child(5) { background: var(--red); } +header { display: flex; align-items: center; gap: 20px; margin-bottom: 32px; } +.eyebrow { font: 13px/1.4 var(--font-mono); color: var(--red); letter-spacing: .04em; margin-bottom: 8px; } +.eyebrow::before { content: "// "; color: var(--text-faint); } +.avatar { width: 64px; height: 64px; border-radius: 10px; border: 1px solid var(--line-strong); } +h1 { font-size: 40px; line-height: 1.05; margin: 0; font-weight: 700; letter-spacing: -0.03em; } +h2 { font-size: 19px; line-height: 1.3; margin: 0 0 4px; font-weight: 700; letter-spacing: -0.01em; } +.sub { color: var(--text-dim); font: 13px/1.6 var(--font-mono); margin-top: 8px; } +.card { background: var(--bg-card); border: 1px solid var(--line); border-radius: 10px; padding: 24px; } +.card-sub { color: var(--text-dim); font-size: 13px; margin-bottom: 18px; } .overview { display: grid; grid-template-columns: minmax(220px, 1fr) 3fr; gap: 16px; margin-bottom: 16px; } -.hero-label { color: var(--text-2); font-size: 13px; } -.hero-value { font-size: 56px; font-weight: 650; line-height: 1.1; letter-spacing: -0.02em; margin: 6px 0; } -.tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); - border-radius: 14px; overflow: hidden; border: 1px solid var(--border); } -.tile { background: var(--surface); padding: 16px 20px; } -.tile-label { color: var(--text-2); font-size: 13px; } -.tile-value { font-size: 26px; font-weight: 600; margin-top: 2px; } -.tile-note { color: var(--muted); font-size: 12px; } +.hero-label, .tile-label { color: var(--text-dim); font: 12px/1.4 var(--font-mono); text-transform: lowercase; } +.hero-value { font-size: 64px; font-weight: 700; line-height: 1; letter-spacing: -0.04em; margin: 14px 0 10px; } +.tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); + border-radius: 10px; overflow: hidden; border: 1px solid var(--line); } +.tile { background: var(--bg-card); padding: 18px 22px; } +.tile-value { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; margin-top: 4px; } +.tile-note { color: var(--text-dim); font-size: 12px; } .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; } .full { margin-top: 16px; } -.legend { display: flex; gap: 16px; font-size: 12px; color: var(--text-2); margin-bottom: 10px; } -.legend.wrap { flex-wrap: wrap; gap: 6px 16px; margin: 12px 0 0; } +.legend { display: flex; gap: 16px; font: 12px/1.4 var(--font-mono); color: var(--text-dim); margin-bottom: 12px; } +.legend.wrap { flex-wrap: wrap; gap: 6px 16px; margin: 14px 0 0; } .legend-item { display: inline-flex; align-items: center; gap: 6px; } .key-line { width: 14px; height: 2px; border-radius: 1px; } -.swatch { width: 10px; height: 10px; border-radius: 3px; } -.muted { color: var(--muted); } -.empty { color: var(--muted); margin: 24px 0; text-align: center; } +.swatch { width: 10px; height: 10px; border-radius: 2px; } +.muted { color: var(--text-dim); } +.empty { color: var(--text-dim); margin: 24px 0; text-align: center; } .linechart { display: grid; grid-template-columns: 44px 1fr; grid-template-rows: 240px auto; } .y-axis { position: relative; } -.y-tick { position: absolute; right: 8px; transform: translateY(-50%); font-size: 11px; - color: var(--muted); font-variant-numeric: tabular-nums; } +.y-tick, .end-label, .x-tick { position: absolute; font: 11px/1.4 var(--font-mono); color: var(--text-dim); + font-variant-numeric: tabular-nums; white-space: nowrap; } +.y-tick { right: 8px; transform: translateY(-50%); } .plot { position: relative; outline: none; margin-right: 40px; } .plot svg { width: 100%; height: 100%; display: block; overflow: visible; } -.plot .grid { stroke: var(--grid); stroke-width: 1; vector-effect: non-scaling-stroke; } +.plot .grid { stroke: var(--line); stroke-width: 1; vector-effect: non-scaling-stroke; } .plot .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; } -.plot .area { opacity: 0.10; } +.plot .area { opacity: 0.12; } .end-labels { position: absolute; top: 0; bottom: 0; left: 100%; } -.end-label { position: absolute; left: 8px; transform: translateY(-50%); font-size: 11px; - color: var(--text-2); font-variant-numeric: tabular-nums; } +.end-label { left: 8px; transform: translateY(-50%); color: var(--text); } .x-axis { grid-column: 2; position: relative; height: 22px; margin-right: 40px; } -.x-tick { position: absolute; top: 6px; transform: translateX(-50%); font-size: 11px; color: var(--muted); - white-space: nowrap; } -.crosshair { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--axis); pointer-events: none; } +.x-tick { top: 6px; transform: translateX(-50%); } +.crosshair { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--line-strong); pointer-events: none; } .dot { position: absolute; width: 10px; height: 10px; border-radius: 50%; transform: translate(-50%, -50%); - box-shadow: 0 0 0 2px var(--surface); pointer-events: none; } + box-shadow: 0 0 0 2px var(--bg-card); pointer-events: none; } .bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; } .bar-row { display: grid; grid-template-columns: minmax(90px, min(34%, 220px)) 1fr 52px; align-items: center; gap: 10px; padding: 5px 6px; border-radius: 6px; outline: none; } .bar-row:hover, .bar-row:focus-visible { background: var(--wash); } -.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); } -.bar-track { height: 12px; } -.bar { display: block; height: 100%; background: var(--series-1); border-radius: 0 4px 4px 0; } -.bar-value { text-align: right; font-variant-numeric: tabular-nums; } +.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); font-size: 14px; } +.bar-track { height: 10px; } +.bar { display: block; height: 100%; background: var(--blue); border-radius: 0 2px 2px 0; } +.bar-value { text-align: right; font: 13px/1.4 var(--font-mono); font-variant-numeric: tabular-nums; } .segmented { display: flex; gap: 2px; height: 28px; } -.segment { min-width: 3px; border-radius: 4px; color: #fff; font-size: 12px; font-weight: 600; +.segment { min-width: 3px; border-radius: 4px; color: var(--bg); font: 600 12px/1 var(--font-mono); padding-left: 8px; display: flex; align-items: center; overflow: hidden; outline: none; } -.segment.dark-ink { color: #0b0b0b; } +.segment.other { color: var(--text); } .segment:hover, .segment:focus-visible { filter: brightness(1.08); } .table-head { display: flex; align-items: end; justify-content: space-between; gap: 12px; flex-wrap: wrap; } -.search { background: var(--page); color: var(--text); border: 1px solid var(--border); border-radius: 8px; - padding: 7px 10px; font: inherit; min-width: 220px; } -.table-scroll { overflow-x: auto; margin-top: 12px; } -table { width: 100%; border-collapse: collapse; font-size: 13px; } -th { text-align: left; font-weight: 600; color: var(--text-2); font-size: 12px; padding: 8px 10px; - border-bottom: 1px solid var(--axis); cursor: pointer; user-select: none; white-space: nowrap; } +.search { background: var(--bg); color: var(--text); border: 1px solid var(--line-strong); border-radius: 8px; + padding: 8px 12px; font: 13px/1.4 var(--font-mono); min-width: 240px; } +.search::placeholder { color: var(--text-dim); } +.search:focus-visible { border-color: var(--blue); } +.table-scroll { overflow-x: auto; margin-top: 14px; } +table { width: 100%; border-collapse: collapse; font-size: 14px; } +th { text-align: left; font: 500 12px/1.4 var(--font-mono); text-transform: lowercase; color: var(--text-dim); + padding: 8px 10px; border-bottom: 1px solid var(--line-strong); cursor: pointer; user-select: none; white-space: nowrap; } +th:hover, th[aria-sort] { color: var(--text); } th.num { text-align: right; } -th[aria-sort]::after { content: " ↓"; color: var(--muted); } +th[aria-sort]::after { content: " ↓"; color: var(--red); } th[aria-sort="ascending"]::after { content: " ↑"; } -td { padding: 9px 10px; border-bottom: 1px solid var(--grid); vertical-align: top; } -td.num { text-align: right; font-variant-numeric: tabular-nums; } +td { padding: 10px; border-bottom: 1px solid var(--line); vertical-align: top; } +td.num { text-align: right; font: 13px/1.6 var(--font-mono); font-variant-numeric: tabular-nums; } tr:hover td { background: var(--wash); } -.desc { color: var(--muted); font-size: 12px; max-width: 360px; overflow: hidden; text-overflow: ellipsis; +td a { font-weight: 600; } +.desc { color: var(--text-dim); font-size: 13px; max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.badge { font-size: 10px; color: var(--text-2); border: 1px solid var(--border); border-radius: 999px; - padding: 0 6px; margin-left: 6px; vertical-align: 1px; } -.nowrap { white-space: nowrap; } +.badge { font: 600 10px/1.3 var(--font-mono); letter-spacing: .04em; color: var(--text-dim); background: var(--bg-elev); + border: 1px solid var(--line); border-radius: 4px; padding: 2px 6px; margin-left: 8px; vertical-align: 2px; } +.nowrap { white-space: nowrap; font: 13px/1.6 var(--font-mono); color: var(--text-dim); } .spark-cell { width: 110px; } .spark { width: 100px; height: 24px; display: block; } -.spark polyline { fill: none; stroke: var(--series-1); stroke-width: 1.5; vector-effect: non-scaling-stroke; } -.notice { margin-top: 16px; color: var(--text-2); font-size: 13px; } -.notice summary { cursor: pointer; } +.spark polyline { fill: none; stroke: var(--blue); stroke-width: 1.5; vector-effect: non-scaling-stroke; } +.notice { margin-top: 16px; color: var(--text-dim); font-size: 14px; } +.notice summary { cursor: pointer; color: var(--text); } +.notice summary::marker { color: var(--yellow); } .notice li { margin: 4px 0; } -footer { color: var(--muted); font-size: 12px; margin-top: 24px; } -#tooltip { position: fixed; z-index: 10; pointer-events: none; background: var(--surface); color: var(--text); - border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: 12px; - box-shadow: 0 4px 16px rgba(0,0,0,0.12); max-width: 280px; } +footer { color: var(--text-dim); font-size: 13px; margin-top: 40px; padding-top: 24px; border-top: 1px solid var(--line); } +footer p { margin: 0 0 20px; max-width: 80ch; } +.foot-row { display: flex; align-items: center; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap; + font: 13px/1.4 var(--font-mono); } +.brand { display: inline-flex; align-items: center; gap: 12px; color: var(--text); font: 700 15px/1 var(--font-display); + letter-spacing: -0.01em; } +/* Layered Core mark: the front square with a red core; offset shadows draw the yellow and blue layers behind it. */ +.mark { --s: 18px; --b: 2.25px; --o: 4.5px; --core: 6px; + width: var(--s); height: var(--s); flex: none; margin: calc(var(--o) * 2) calc(var(--o) * 2) 0 0; + display: grid; place-items: center; background: var(--bg); border: var(--b) solid var(--text); + border-radius: calc(var(--s) * 0.214); + box-shadow: var(--o) calc(var(--o) * -1) 0 calc(var(--b) * -1) var(--bg), var(--o) calc(var(--o) * -1) 0 0 var(--yellow), + calc(var(--o) * 2) calc(var(--o) * -2) 0 calc(var(--b) * -1) var(--bg), calc(var(--o) * 2) calc(var(--o) * -2) 0 0 var(--blue); } +.mark::after { content: ""; width: var(--core); height: var(--core); background: var(--red); border-radius: 1px; } +#tooltip { position: fixed; z-index: 10; pointer-events: none; background: var(--bg-elev); color: var(--text); + border: 1px solid var(--line-strong); border-radius: 8px; padding: 8px 10px; font: 12px/1.5 var(--font-mono); + max-width: 300px; } #tooltip .tt-row { display: flex; align-items: center; gap: 6px; } #tooltip .tt-val { margin-left: auto; padding-left: 12px; font-variant-numeric: tabular-nums; font-weight: 600; } @media (max-width: 860px) { @@ -414,31 +430,20 @@ def repo_bars(attr: str, unit: str) -> list[tuple[str, int, str, str]]: .tiles { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 520px) { - .wrap-page { padding: 20px 16px 48px; } - .hero-value { font-size: 48px; } + .wrap-page { padding: 28px 16px 48px; } + .hero-value { font-size: 52px; } .search { min-width: 0; width: 100%; } - h1 { font-size: 20px; } - .avatar { width: 40px; height: 40px; } - .theme-toggle { padding: 5px 9px; font-size: 12px; } + h1 { font-size: 28px; } + .avatar { width: 44px; height: 44px; } .x-tick.alt { display: none; } } +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; } +} """ JS = r""" (() => { - const root = document.documentElement; - const toggle = document.querySelector('.theme-toggle'); - const prefersDark = () => matchMedia('(prefers-color-scheme: dark)').matches; - const current = () => root.dataset.theme || (prefersDark() ? 'dark' : 'light'); - try { const saved = localStorage.getItem('gitpulse-theme'); if (saved) root.dataset.theme = saved; } catch (e) {} - const label = () => { toggle.textContent = current() === 'dark' ? 'Light mode' : 'Dark mode'; }; - toggle.addEventListener('click', () => { - root.dataset.theme = current() === 'dark' ? 'light' : 'dark'; - try { localStorage.setItem('gitpulse-theme', root.dataset.theme); } catch (e) {} - label(); - }); - label(); - const tip = document.getElementById('tooltip'); const fmt = (n) => n.toLocaleString(); const show = (html, x, y) => { @@ -530,17 +535,22 @@ def repo_bars(attr: str, unit: str) -> list[tuple[str, int, str, str]]: {title} + + + +
+
{avatar}
+
gitpulse

{display}

@{owner} · {repo_count} repositories · traffic {window} · generated {generated}
-
@@ -611,9 +621,12 @@ def repo_bars(attr: str, unit: str) -> list[tuple[str, int, str, str]]: {missing}
- Generated by GitPulse. GitHub reports aggregate traffic for roughly the last 14 days and never identifies - individual visitors. Unique counts are summed per repository and per day, so one person can be counted - more than once. +

GitHub reports aggregate traffic for roughly the last 14 days and never identifies individual visitors. + Unique counts are summed per repository and per day, so one person can be counted more than once.

+