gitpulse-analytics 0.2.1__tar.gz → 1.0.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.
@@ -6,6 +6,28 @@ All notable changes to GitPulse are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.0.0] - 2026-11-08
10
+
11
+ GitPulse is out of beta. From this release, the commands, their options and
12
+ the snapshot database follow semantic versioning: breaking changes wait for 2.0.
13
+
14
+ ### Added
15
+
16
+ - A GitPulse logo in the Full-Stack-Spectrum style: the Layered Core stack with
17
+ a red pulse line. The README opens with a banner, and the dashboard uses the
18
+ icon as its browser-tab icon.
19
+
20
+ ### Changed
21
+
22
+ - The HTML dashboard and terminal summary use the Full-Stack-Spectrum brand:
23
+ near-black surfaces with a dot grid, Space Grotesk and JetBrains Mono, and
24
+ the five-colour spectrum (blue, blue-soft, yellow, orange, red) for charts.
25
+ - The dashboard is dark only; the light theme and its toggle are gone.
26
+ - The language breakdown shows the top five languages plus Other, down from
27
+ seven, so each gets a spectrum colour.
28
+ - The dashboard loads its two fonts from Google Fonts and falls back to system
29
+ fonts offline.
30
+
9
31
  ## [0.2.1] - 2026-11-03
10
32
 
11
33
  ### Security
@@ -54,6 +76,7 @@ First public release.
54
76
  [`fullstackspectrum/gitpulse`](https://github.com/fullstackspectrum/homebrew-gitpulse)
55
77
  Homebrew tap, and as the multi-arch `fullstackspectrum/gitpulse` Docker image.
56
78
 
57
- [Unreleased]: https://github.com/fullstackspectrum/gitpulse/compare/v0.2.1...HEAD
79
+ [Unreleased]: https://github.com/fullstackspectrum/gitpulse/compare/v1.0.0...HEAD
80
+ [1.0.0]: https://github.com/fullstackspectrum/gitpulse/compare/v0.2.1...v1.0.0
58
81
  [0.2.1]: https://github.com/fullstackspectrum/gitpulse/compare/v0.2.0...v0.2.1
59
82
  [0.2.0]: https://github.com/fullstackspectrum/gitpulse/releases/tag/v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitpulse-analytics
3
- Version: 0.2.1
3
+ Version: 1.0.0
4
4
  Summary: GitHub traffic, clones, referrers, and release downloads in your terminal, with org-wide HTML dashboards
5
5
  Project-URL: Homepage, https://github.com/fullstackspectrum/gitpulse
6
6
  Project-URL: Issues, https://github.com/fullstackspectrum/gitpulse/issues
@@ -9,7 +9,7 @@ Author-email: FullStackSpectrum <dev@fullstackspectrum.com>
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Keywords: analytics,cli,dashboard,downloads,github,releases,traffic
12
- Classifier: Development Status :: 4 - Beta
12
+ Classifier: Development Status :: 5 - Production/Stable
13
13
  Classifier: Environment :: Console
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: Operating System :: OS Independent
@@ -30,23 +30,25 @@ Requires-Dist: pytest<9,>=8; extra == 'dev'
30
30
  Requires-Dist: ruff<1,>=0.6; extra == 'dev'
31
31
  Description-Content-Type: text/markdown
32
32
 
33
- # GitPulse
33
+ [![GitPulse: GitHub traffic, kept past the 14-day window.](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/brand/readme-banner.png)](https://github.com/fullstackspectrum/gitpulse)
34
34
 
35
- [![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)
36
- [![PyPI](https://img.shields.io/pypi/v/gitpulse-analytics?style=for-the-badge&logo=pypi&logoColor=white&color=3775A9)](https://pypi.org/project/gitpulse-analytics/)
37
- [![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/)
38
- [![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)
39
- [![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)
40
- [![License](https://img.shields.io/github/license/fullstackspectrum/gitpulse?style=for-the-badge&color=6E7781)](LICENSE)
35
+ [![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)
36
+ [![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/)
37
+ [![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/)
38
+ [![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)
39
+ [![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)
40
+ [![License](https://img.shields.io/github/license/fullstackspectrum/gitpulse?style=for-the-badge&color=646f7f&labelColor=10151d)](LICENSE)
41
41
  <br>
42
- [![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)
43
- [![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)
42
+ [![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)
43
+ [![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)
44
44
 
45
45
  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.
46
46
 
47
+ Part of [Full-Stack-Spectrum](https://fullstackspectrum.com) ↗: small, sharp, open-source utilities that each do one job and install in one line.
48
+
47
49
  > **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.
48
50
 
49
- ![GitPulse organization dashboard](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard-light.png)
51
+ ![GitPulse organization dashboard](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard.png)
50
52
 
51
53
  ## Requirements
52
54
 
@@ -130,9 +132,7 @@ It also writes a self-contained HTML dashboard to `gitpulse-ORG.html` in the cur
130
132
  - The most viewed and most starred repositories, a language breakdown, top referrers, and release downloads by repository
131
133
  - A sortable, filterable table of every repository with a 14-day views sparkline
132
134
 
133
- 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.
134
-
135
- ![Dashboard in dark mode](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard-dark.png)
135
+ 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.
136
136
 
137
137
  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.
138
138
 
@@ -1,20 +1,22 @@
1
- # GitPulse
2
-
3
- [![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)
4
- [![PyPI](https://img.shields.io/pypi/v/gitpulse-analytics?style=for-the-badge&logo=pypi&logoColor=white&color=3775A9)](https://pypi.org/project/gitpulse-analytics/)
5
- [![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/)
6
- [![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)
7
- [![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)
8
- [![License](https://img.shields.io/github/license/fullstackspectrum/gitpulse?style=for-the-badge&color=6E7781)](LICENSE)
1
+ [![GitPulse: GitHub traffic, kept past the 14-day window.](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/brand/readme-banner.png)](https://github.com/fullstackspectrum/gitpulse)
2
+
3
+ [![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)
4
+ [![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/)
5
+ [![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/)
6
+ [![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)
7
+ [![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)
8
+ [![License](https://img.shields.io/github/license/fullstackspectrum/gitpulse?style=for-the-badge&color=646f7f&labelColor=10151d)](LICENSE)
9
9
  <br>
10
- [![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)
11
- [![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)
10
+ [![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)
11
+ [![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)
12
12
 
13
13
  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.
14
14
 
15
+ Part of [Full-Stack-Spectrum](https://fullstackspectrum.com) ↗: small, sharp, open-source utilities that each do one job and install in one line.
16
+
15
17
  > **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.
16
18
 
17
- ![GitPulse organization dashboard](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard-light.png)
19
+ ![GitPulse organization dashboard](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard.png)
18
20
 
19
21
  ## Requirements
20
22
 
@@ -98,9 +100,7 @@ It also writes a self-contained HTML dashboard to `gitpulse-ORG.html` in the cur
98
100
  - The most viewed and most starred repositories, a language breakdown, top referrers, and release downloads by repository
99
101
  - A sortable, filterable table of every repository with a 14-day views sparkline
100
102
 
101
- 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.
102
-
103
- ![Dashboard in dark mode](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard-dark.png)
103
+ 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.
104
104
 
105
105
  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.
106
106
 
@@ -0,0 +1,10 @@
1
+ # GitPulse brand assets
2
+
3
+ GitPulse follows the [Full-Stack-Spectrum](https://fullstackspectrum.com) design system.
4
+
5
+ - `gitpulse-icon.svg`: the mark on a 64×64 `#0a0d12` tile (radius 14), for avatars and app icons. The dashboard inlines it as its tab icon (`ICON` in `src/gitpulse/report.py`); keep the two in sync.
6
+ - `gitpulse-mark.svg`: the mark alone, for dark grounds only (its layers are filled `#0a0d12`).
7
+ - `readme-banner.png`: 2560×640, shown at 1280×320 at the top of the README.
8
+ - `github-social-preview.png`: 1280×640, uploaded under the repository's Settings → Social preview.
9
+
10
+ The mark is Layered Core with the red core replaced by a pulse line: blue and yellow layers behind a `#eef1f6` outline, cool at the back, hot at the front.
@@ -13,7 +13,7 @@ license-files = ["LICENSE"]
13
13
  authors = [{ name = "FullStackSpectrum", email = "dev@fullstackspectrum.com" }]
14
14
  keywords = ["github", "traffic", "analytics", "dashboard", "cli", "releases", "downloads"]
15
15
  classifiers = [
16
- "Development Status :: 4 - Beta",
16
+ "Development Status :: 5 - Production/Stable",
17
17
  "Environment :: Console",
18
18
  "Intended Audience :: Developers",
19
19
  "Operating System :: OS Independent",
@@ -1,3 +1,3 @@
1
1
  """Local GitHub repository analytics."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "1.0.0"
@@ -19,6 +19,7 @@ from rich.progress import (
19
19
  TimeElapsedColumn,
20
20
  )
21
21
  from rich.table import Table
22
+ from rich.theme import Theme
22
23
 
23
24
  from gitpulse import __version__
24
25
  from gitpulse.github_api import GitHubAPIError, GitHubClient
@@ -31,7 +32,26 @@ app = typer.Typer(
31
32
  help="Local GitHub repository traffic and release analytics.",
32
33
  no_args_is_help=True,
33
34
  )
34
- console = Console()
35
+ # Full-Stack-Spectrum palette (fullstackspectrum.com): red leads, the rest of the spectrum marks data.
36
+ THEME = Theme(
37
+ {
38
+ "brand": "bold #ff4d5e",
39
+ "heading": "bold #8fb4ff",
40
+ "frame": "#2f3a4b",
41
+ "views": "#4f8cff",
42
+ "clones": "#ff8a3d",
43
+ "stars": "#f5c84c",
44
+ "downloads": "#8fb4ff",
45
+ "ok": "#8fb4ff",
46
+ "warn": "#f5c84c",
47
+ "error": "bold #ff4d5e",
48
+ "progress.spinner": "#ff4d5e",
49
+ "bar.complete": "#ff4d5e",
50
+ "bar.finished": "#4f8cff",
51
+ "bar.pulse": "#ff4d5e",
52
+ }
53
+ )
54
+ console = Console(theme=THEME)
35
55
 
36
56
 
37
57
  def _version_callback(value: bool) -> None:
@@ -53,7 +73,7 @@ def _client() -> GitHubClient:
53
73
  try:
54
74
  return GitHubClient()
55
75
  except GitHubAPIError as exc:
56
- console.print(f"[red]Error:[/red] {exc}")
76
+ console.print(f"[error]Error:[/error] {exc}")
57
77
  raise typer.Exit(code=1) from exc
58
78
 
59
79
 
@@ -61,7 +81,7 @@ def _run(action: Any) -> Any:
61
81
  try:
62
82
  return action()
63
83
  except GitHubAPIError as exc:
64
- console.print(f"[red]Error:[/red] {exc}")
84
+ console.print(f"[error]Error:[/error] {exc}")
65
85
  raise typer.Exit(code=1) from exc
66
86
 
67
87
 
@@ -73,7 +93,7 @@ def _repo_parts(repository: str) -> tuple[str, str]:
73
93
 
74
94
 
75
95
  def _traffic_table(owner: str, repo: str, metric: str, data: dict[str, Any]) -> Table:
76
- table = Table(title=f"{owner}/{repo} · {metric}", header_style="bold cyan")
96
+ table = Table(title=f"{owner}/{repo} · {metric}", header_style="heading")
77
97
  if metric in {"views", "clones"}:
78
98
  table.add_column("Date")
79
99
  table.add_column("Count", justify="right")
@@ -110,7 +130,7 @@ def list_repos(
110
130
  repos = _run(_client().list_repositories)
111
131
  if not all_repos:
112
132
  repos = [repo for repo in repos if not repo.get("fork") and not repo.get("archived")]
113
- table = Table(title="Your repositories", header_style="bold cyan")
133
+ table = Table(title="Your repositories", header_style="heading")
114
134
  table.add_column("Repository")
115
135
  table.add_column("Visibility")
116
136
  table.add_column("Stars", justify="right")
@@ -143,7 +163,7 @@ def traffic(
143
163
  console.print(_traffic_table(owner, repo, metric, data))
144
164
  if save and metric in {"views", "clones"}:
145
165
  SnapshotStore().save(owner, repo, metric, data)
146
- console.print("[green]Snapshot saved locally.[/green]")
166
+ console.print("[ok]Snapshot saved locally.[/ok]")
147
167
  console.print("[dim]GitHub provides aggregate traffic data, not visitor identities; traffic history is typically limited to 14 days.[/dim]")
148
168
 
149
169
 
@@ -159,7 +179,7 @@ def snapshot(
159
179
  data = _run(lambda metric=metric: client.traffic(owner, repo, metric))
160
180
  store.save(owner, repo, metric, data)
161
181
  console.print(_traffic_table(owner, repo, metric, data))
162
- console.print("[green]Snapshots saved. Run this regularly to build longer-term history.[/green]")
182
+ console.print("[ok]Snapshots saved. Run this regularly to build longer-term history.[/ok]")
163
183
 
164
184
 
165
185
  @app.command()
@@ -173,7 +193,7 @@ def history(
173
193
  if metric not in {"views", "clones"}:
174
194
  raise typer.BadParameter("History is available for views or clones")
175
195
  rows = SnapshotStore().history(owner, repo, metric, limit)
176
- table = Table(title=f"Local history · {owner}/{repo} · {metric}", header_style="bold cyan")
196
+ table = Table(title=f"Local history · {owner}/{repo} · {metric}", header_style="heading")
177
197
  table.add_column("Collected at")
178
198
  table.add_column("14-day total", justify="right")
179
199
  table.add_column("Sum of daily uniques*", justify="right")
@@ -199,7 +219,7 @@ def release_downloads(
199
219
  """Show GitHub release asset download counts (not Homebrew install counts)."""
200
220
  owner, repo = _repo_parts(repository)
201
221
  release_count, assets = _run(lambda: _client().release_downloads(owner, repo))
202
- table = Table(title=f"Release asset downloads · {owner}/{repo}", header_style="bold cyan")
222
+ table = Table(title=f"Release asset downloads · {owner}/{repo}", header_style="heading")
203
223
  table.add_column("Release")
204
224
  table.add_column("Asset")
205
225
  table.add_column("Downloads", justify="right")
@@ -237,22 +257,24 @@ def _org_summary(report: OrgReport) -> None:
237
257
  name = report.profile.get("name") or report.owner
238
258
  console.print(
239
259
  Panel.fit(
260
+ f"[dim]//[/dim] [brand]gitpulse[/brand]\n"
240
261
  f"[bold]{name}[/bold] [dim]@{report.owner}[/dim]\n"
241
262
  f"{len(report.repos)} repositories · traffic {window}",
242
- border_style="cyan",
263
+ border_style="frame",
243
264
  )
244
265
  )
245
266
 
246
- def tile(label: str, value: int, style: str = "bold") -> Panel:
247
- return Panel(f"[{style}]{value:,}[/{style}]\n[dim]{label}[/dim]", expand=True, border_style="dim")
267
+ def tile(label: str, value: int, style: str = "") -> Panel:
268
+ number = f"[{style}]{value:,}[/{style}]" if style else f"{value:,}"
269
+ return Panel(f"[bold]{number}[/bold]\n[dim]{label}[/dim]", expand=True, border_style="frame")
248
270
 
249
271
  tiles = [
250
- tile("views · 14d", report.total("views"), "bold cyan"),
272
+ tile("views · 14d", report.total("views"), "views"),
251
273
  tile("unique visitors*", report.total("view_uniques")),
252
- tile("clones · 14d", report.total("clones"), "bold magenta"),
253
- tile("stars", report.total("stars"), "bold yellow"),
274
+ tile("clones · 14d", report.total("clones"), "clones"),
275
+ tile("stars", report.total("stars"), "stars"),
254
276
  tile("forks", report.total("forks")),
255
- tile("release downloads", report.total("downloads"), "bold green"),
277
+ tile("release downloads", report.total("downloads"), "downloads"),
256
278
  ]
257
279
  grid = Table.grid(expand=True, padding=(0, 1))
258
280
  for _ in range(3):
@@ -261,11 +283,11 @@ def _org_summary(report: OrgReport) -> None:
261
283
  grid.add_row(*tiles[3:])
262
284
  console.print(grid)
263
285
  if views:
264
- console.print(f" [dim]daily views[/dim] [cyan]{_spark([c for _, c in views])}[/cyan]")
286
+ console.print(f" [dim]daily views[/dim] [views]{_spark([c for _, c in views])}[/views]")
265
287
  clones = [c for _, c in report.daily("clones_daily")]
266
- console.print(f" [dim]daily clones[/dim] [magenta]{_spark(clones)}[/magenta]\n")
288
+ console.print(f" [dim]daily clones[/dim] [clones]{_spark(clones)}[/clones]\n")
267
289
 
268
- table = Table(header_style="bold cyan", box=box.SIMPLE_HEAD, expand=True)
290
+ table = Table(header_style="heading", box=box.SIMPLE_HEAD, expand=True)
269
291
  table.add_column("Repository", overflow="fold")
270
292
  table.add_column("Lang", style="dim")
271
293
  for column in ("Stars", "Forks", "Views", "Unique", "Clones", "Downloads"):
@@ -283,13 +305,13 @@ def _org_summary(report: OrgReport) -> None:
283
305
  f"{repo.view_uniques:,}" if ok else na,
284
306
  f"{repo.clones:,}" if ok else na,
285
307
  f"{repo.downloads:,}" if repo.releases_error is None else na,
286
- f"[cyan]{_spark([repo.views_daily.get(d, 0) for d in days])}[/cyan]" if ok and days else "",
308
+ f"[views]{_spark([repo.views_daily.get(d, 0) for d in days])}[/views]" if ok and days else "",
287
309
  )
288
310
  console.print(table)
289
311
 
290
312
  referrers = report.referrers(limit=5)
291
313
  if referrers:
292
- ref_table = Table(title="Top referrers", header_style="bold cyan", box=box.SIMPLE_HEAD)
314
+ ref_table = Table(title="Top referrers", header_style="heading", box=box.SIMPLE_HEAD)
293
315
  ref_table.add_column("Source")
294
316
  ref_table.add_column("Views", justify="right")
295
317
  ref_table.add_column("Unique*", justify="right")
@@ -300,7 +322,7 @@ def _org_summary(report: OrgReport) -> None:
300
322
  missing = [repo for repo in report.repos if repo.traffic_error]
301
323
  if missing:
302
324
  console.print(
303
- f"[yellow]Traffic unavailable for {len(missing)} of {len(report.repos)} repositories[/yellow] "
325
+ f"[warn]Traffic unavailable for {len(missing)} of {len(report.repos)} repositories[/warn] "
304
326
  "[dim](the token needs Administration: Read or push access). See the dashboard for details.[/dim]"
305
327
  )
306
328
  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(
354
376
  for metric, payload in repo.raw_traffic.items():
355
377
  store.save(repo.owner, repo.name, metric, payload)
356
378
  saved += 1
357
- console.print(f"[green]Saved snapshots for {saved} repositories.[/green]")
379
+ console.print(f"[ok]Saved snapshots for {saved} repositories.[/ok]")
358
380
 
359
381
  path = Path(output or f"gitpulse-{report.owner}.html").expanduser().resolve()
360
382
  path.write_text(render_html(report), encoding="utf-8")
361
- console.print(f"\n[bold green]✓[/bold green] Dashboard written to [link={path.as_uri()}]{path}[/link]")
383
+ console.print(f"\n[ok]✓[/ok] Dashboard written to [link={path.as_uri()}]{path}[/link]")
362
384
  if open_browser:
363
385
  webbrowser.open(path.as_uri())
364
386
 
@@ -1,4 +1,7 @@
1
- """Render an OrgReport as a self-contained HTML dashboard (no external scripts or styles)."""
1
+ """Render an OrgReport as a self-contained HTML dashboard in the Full-Stack-Spectrum style.
2
+
3
+ Everything is inline except the two web fonts, which fall back to system fonts offline.
4
+ """
2
5
 
3
6
  from __future__ import annotations
4
7
 
@@ -6,12 +9,22 @@ import json
6
9
  import math
7
10
  from html import escape
8
11
  from typing import Any
12
+ from urllib.parse import quote
9
13
 
10
14
  from gitpulse.org import OrgReport, RepoStats
11
15
 
12
- # Categorical slots in fixed order; slot 4 (yellow) takes dark ink for labels inside it.
13
- SERIES = 8
14
- DARK_INK_SLOTS = {4}
16
+ # The GitPulse mark (docs/brand/gitpulse-icon.svg), inlined as the tab icon.
17
+ ICON = (
18
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0a0d12"/>'
19
+ '<rect x="25" y="11" width="28" height="28" rx="6" fill="#0a0d12" stroke="#4f8cff" stroke-width="3.5"/>'
20
+ '<rect x="18" y="18" width="28" height="28" rx="6" fill="#0a0d12" stroke="#f5c84c" stroke-width="3.5"/>'
21
+ '<rect x="11" y="25" width="28" height="28" rx="6" fill="#0a0d12" stroke="#eef1f6" stroke-width="3.5"/>'
22
+ '<path d="M16.5 40h4.5l2.75-6.5 4.5 12 2.75-5.5h3.5" fill="none" stroke="#ff4d5e" stroke-width="3" '
23
+ 'stroke-linecap="round" stroke-linejoin="round"/></svg>'
24
+ )
25
+
26
+ # Categorical slots follow the five-colour spectrum, cool to hot; a sixth slot is "Other".
27
+ SERIES = 6
15
28
 
16
29
 
17
30
  def compact(value: float) -> str:
@@ -157,7 +170,7 @@ def _languages(report: OrgReport) -> str:
157
170
  for slot, (name, count) in enumerate(shown, start=1):
158
171
  var = "--muted-fill" if name == "Other" else f"--series-{slot}"
159
172
  share = count / total * 100
160
- ink = " dark-ink" if slot in DARK_INK_SLOTS and name != "Other" else ""
173
+ ink = " other" if name == "Other" else ""
161
174
  label = f"{share:.0f}%" if share >= 9 else ""
162
175
  blocks.append(
163
176
  f'<span class="segment{ink}" tabindex="0" style="flex:{count};background:var({var})" '
@@ -258,7 +271,7 @@ def render_html(report: OrgReport) -> str:
258
271
  )
259
272
 
260
273
  line = _line_chart(
261
- [("Views", "--series-1", views_daily), ("Clones", "--series-2", clones_daily)]
274
+ [("Views", "--blue", views_daily), ("Clones", "--orange", clones_daily)]
262
275
  )
263
276
  return TEMPLATE.format(
264
277
  title=escape(f"{display} · GitPulse"),
@@ -281,6 +294,7 @@ def render_html(report: OrgReport) -> str:
281
294
  downloads=_bar_list(repo_bars("downloads", "downloads"), "No release asset downloads."),
282
295
  rows=rows,
283
296
  missing=missing_html,
297
+ icon=_attr("data:image/svg+xml," + quote(ICON)),
284
298
  css=CSS,
285
299
  js=JS,
286
300
  )
@@ -288,125 +302,139 @@ def render_html(report: OrgReport) -> str:
288
302
 
289
303
  CSS = """
290
304
  :root {
291
- color-scheme: light;
292
- --page: #f9f9f7; --surface: #fcfcfb; --text: #0b0b0b; --text-2: #52514e; --muted: #898781;
293
- --grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11,11,11,0.10); --wash: rgba(11,11,11,0.04);
294
- --accent: #2a78d6; --muted-fill: #b4b2a9;
295
- --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --series-4: #eda100;
296
- --series-5: #e87ba4; --series-6: #008300; --series-7: #6250d6; --series-8: #e34948;
297
- }
298
- @media (prefers-color-scheme: dark) {
299
- :root:not([data-theme="light"]) {
300
- color-scheme: dark;
301
- --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781;
302
- --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05);
303
- --accent: #3987e5; --muted-fill: #5f5e5a;
304
- --series-1: #3987e5; --series-2: #d95926; --series-3: #199e70; --series-4: #c98500;
305
- --series-5: #d55181; --series-6: #008300; --series-7: #9085e9; --series-8: #e66767;
306
- }
307
- }
308
- :root[data-theme="dark"] {
309
305
  color-scheme: dark;
310
- --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781;
311
- --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05);
312
- --accent: #3987e5; --muted-fill: #5f5e5a;
313
- --series-1: #3987e5; --series-2: #d95926; --series-3: #199e70; --series-4: #c98500;
314
- --series-5: #d55181; --series-6: #008300; --series-7: #9085e9; --series-8: #e66767;
306
+ /* Full-Stack-Spectrum tokens (fullstackspectrum.com). Dark only, by design. */
307
+ --bg: #0a0d12; --bg-elev: #10151d; --bg-card: #121821;
308
+ --line: #222b38; --line-strong: #2f3a4b;
309
+ --text: #eef1f6; --text-dim: #9aa4b2; --text-faint: #646f7f;
310
+ --blue: #4f8cff; --blue-soft: #8fb4ff; --yellow: #f5c84c; --orange: #ff8a3d; --red: #ff4d5e;
311
+ --wash: rgba(255,255,255,0.04); --muted-fill: #646f7f;
312
+ /* Categorical slots run the spectrum cool to hot. */
313
+ --series-1: var(--blue); --series-2: var(--blue-soft); --series-3: var(--yellow);
314
+ --series-4: var(--orange); --series-5: var(--red);
315
+ --font-display: "Space Grotesk", system-ui, -apple-system, "Segoe UI", sans-serif;
316
+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
315
317
  }
316
318
  * { box-sizing: border-box; }
317
- body { margin: 0; background: var(--page); color: var(--text);
318
- font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
319
- a { color: inherit; text-decoration: none; }
320
- a:hover { text-decoration: underline; }
321
- .wrap-page { max-width: 1200px; margin: 0 auto; padding: 32px 24px 64px; }
322
- header { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }
323
- .avatar { width: 56px; height: 56px; border-radius: 12px; border: 1px solid var(--border); }
324
- h1 { font-size: 24px; margin: 0; font-weight: 650; letter-spacing: -0.01em; }
325
- h2 { font-size: 15px; margin: 0 0 4px; font-weight: 600; }
326
- .sub { color: var(--text-2); font-size: 13px; }
327
- .theme-toggle { margin-left: auto; flex-shrink: 0; white-space: nowrap; background: var(--surface); color: var(--text-2);
328
- border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; font: inherit; cursor: pointer; }
329
- .theme-toggle:hover { background: var(--wash); }
330
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 20px; }
331
- .card-sub { color: var(--muted); font-size: 12px; margin-bottom: 16px; }
319
+ body { margin: 0; color: var(--text); background-color: var(--bg);
320
+ background-image: radial-gradient(circle at 1px 1px, rgba(255,255,255,0.07) 1px, transparent 0);
321
+ background-size: 28px 28px;
322
+ font: 15px/1.6 var(--font-display); -webkit-font-smoothing: antialiased; }
323
+ a { color: inherit; text-decoration: none; transition: color .15s; }
324
+ a:hover { color: var(--blue-soft); }
325
+ :focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; }
326
+ ::selection { background: var(--red); color: var(--bg); }
327
+ .wrap-page { max-width: 1160px; margin: 0 auto; padding: 48px clamp(16px, 4vw, 40px) 64px; }
328
+ .spectrum { display: flex; gap: 6px; margin-bottom: 28px; }
329
+ .spectrum span { width: 28px; height: 8px; border-radius: 1px; }
330
+ .spectrum span:nth-child(1) { background: var(--blue); }
331
+ .spectrum span:nth-child(2) { background: var(--blue-soft); }
332
+ .spectrum span:nth-child(3) { background: var(--yellow); }
333
+ .spectrum span:nth-child(4) { background: var(--orange); }
334
+ .spectrum span:nth-child(5) { background: var(--red); }
335
+ header { display: flex; align-items: center; gap: 20px; margin-bottom: 32px; }
336
+ .eyebrow { font: 13px/1.4 var(--font-mono); color: var(--red); letter-spacing: .04em; margin-bottom: 8px; }
337
+ .eyebrow::before { content: "// "; color: var(--text-faint); }
338
+ .avatar { width: 64px; height: 64px; border-radius: 10px; border: 1px solid var(--line-strong); }
339
+ h1 { font-size: 40px; line-height: 1.05; margin: 0; font-weight: 700; letter-spacing: -0.03em; }
340
+ h2 { font-size: 19px; line-height: 1.3; margin: 0 0 4px; font-weight: 700; letter-spacing: -0.01em; }
341
+ .sub { color: var(--text-dim); font: 13px/1.6 var(--font-mono); margin-top: 8px; }
342
+ .card { background: var(--bg-card); border: 1px solid var(--line); border-radius: 10px; padding: 24px; }
343
+ .card-sub { color: var(--text-dim); font-size: 13px; margin-bottom: 18px; }
332
344
  .overview { display: grid; grid-template-columns: minmax(220px, 1fr) 3fr; gap: 16px; margin-bottom: 16px; }
333
- .hero-label { color: var(--text-2); font-size: 13px; }
334
- .hero-value { font-size: 56px; font-weight: 650; line-height: 1.1; letter-spacing: -0.02em; margin: 6px 0; }
335
- .tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border);
336
- border-radius: 14px; overflow: hidden; border: 1px solid var(--border); }
337
- .tile { background: var(--surface); padding: 16px 20px; }
338
- .tile-label { color: var(--text-2); font-size: 13px; }
339
- .tile-value { font-size: 26px; font-weight: 600; margin-top: 2px; }
340
- .tile-note { color: var(--muted); font-size: 12px; }
345
+ .hero-label, .tile-label { color: var(--text-dim); font: 12px/1.4 var(--font-mono); text-transform: lowercase; }
346
+ .hero-value { font-size: 64px; font-weight: 700; line-height: 1; letter-spacing: -0.04em; margin: 14px 0 10px; }
347
+ .tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line);
348
+ border-radius: 10px; overflow: hidden; border: 1px solid var(--line); }
349
+ .tile { background: var(--bg-card); padding: 18px 22px; }
350
+ .tile-value { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; margin-top: 4px; }
351
+ .tile-note { color: var(--text-dim); font-size: 12px; }
341
352
  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
342
353
  .full { margin-top: 16px; }
343
- .legend { display: flex; gap: 16px; font-size: 12px; color: var(--text-2); margin-bottom: 10px; }
344
- .legend.wrap { flex-wrap: wrap; gap: 6px 16px; margin: 12px 0 0; }
354
+ .legend { display: flex; gap: 16px; font: 12px/1.4 var(--font-mono); color: var(--text-dim); margin-bottom: 12px; }
355
+ .legend.wrap { flex-wrap: wrap; gap: 6px 16px; margin: 14px 0 0; }
345
356
  .legend-item { display: inline-flex; align-items: center; gap: 6px; }
346
357
  .key-line { width: 14px; height: 2px; border-radius: 1px; }
347
- .swatch { width: 10px; height: 10px; border-radius: 3px; }
348
- .muted { color: var(--muted); }
349
- .empty { color: var(--muted); margin: 24px 0; text-align: center; }
358
+ .swatch { width: 10px; height: 10px; border-radius: 2px; }
359
+ .muted { color: var(--text-dim); }
360
+ .empty { color: var(--text-dim); margin: 24px 0; text-align: center; }
350
361
  .linechart { display: grid; grid-template-columns: 44px 1fr; grid-template-rows: 240px auto; }
351
362
  .y-axis { position: relative; }
352
- .y-tick { position: absolute; right: 8px; transform: translateY(-50%); font-size: 11px;
353
- color: var(--muted); font-variant-numeric: tabular-nums; }
363
+ .y-tick, .end-label, .x-tick { position: absolute; font: 11px/1.4 var(--font-mono); color: var(--text-dim);
364
+ font-variant-numeric: tabular-nums; white-space: nowrap; }
365
+ .y-tick { right: 8px; transform: translateY(-50%); }
354
366
  .plot { position: relative; outline: none; margin-right: 40px; }
355
367
  .plot svg { width: 100%; height: 100%; display: block; overflow: visible; }
356
- .plot .grid { stroke: var(--grid); stroke-width: 1; vector-effect: non-scaling-stroke; }
368
+ .plot .grid { stroke: var(--line); stroke-width: 1; vector-effect: non-scaling-stroke; }
357
369
  .plot .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round;
358
370
  vector-effect: non-scaling-stroke; }
359
- .plot .area { opacity: 0.10; }
371
+ .plot .area { opacity: 0.12; }
360
372
  .end-labels { position: absolute; top: 0; bottom: 0; left: 100%; }
361
- .end-label { position: absolute; left: 8px; transform: translateY(-50%); font-size: 11px;
362
- color: var(--text-2); font-variant-numeric: tabular-nums; }
373
+ .end-label { left: 8px; transform: translateY(-50%); color: var(--text); }
363
374
  .x-axis { grid-column: 2; position: relative; height: 22px; margin-right: 40px; }
364
- .x-tick { position: absolute; top: 6px; transform: translateX(-50%); font-size: 11px; color: var(--muted);
365
- white-space: nowrap; }
366
- .crosshair { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--axis); pointer-events: none; }
375
+ .x-tick { top: 6px; transform: translateX(-50%); }
376
+ .crosshair { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--line-strong); pointer-events: none; }
367
377
  .dot { position: absolute; width: 10px; height: 10px; border-radius: 50%; transform: translate(-50%, -50%);
368
- box-shadow: 0 0 0 2px var(--surface); pointer-events: none; }
378
+ box-shadow: 0 0 0 2px var(--bg-card); pointer-events: none; }
369
379
  .bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
370
380
  .bar-row { display: grid; grid-template-columns: minmax(90px, min(34%, 220px)) 1fr 52px; align-items: center; gap: 10px;
371
381
  padding: 5px 6px; border-radius: 6px; outline: none; }
372
382
  .bar-row:hover, .bar-row:focus-visible { background: var(--wash); }
373
- .bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
374
- .bar-track { height: 12px; }
375
- .bar { display: block; height: 100%; background: var(--series-1); border-radius: 0 4px 4px 0; }
376
- .bar-value { text-align: right; font-variant-numeric: tabular-nums; }
383
+ .bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); font-size: 14px; }
384
+ .bar-track { height: 10px; }
385
+ .bar { display: block; height: 100%; background: var(--blue); border-radius: 0 2px 2px 0; }
386
+ .bar-value { text-align: right; font: 13px/1.4 var(--font-mono); font-variant-numeric: tabular-nums; }
377
387
  .segmented { display: flex; gap: 2px; height: 28px; }
378
- .segment { min-width: 3px; border-radius: 4px; color: #fff; font-size: 12px; font-weight: 600;
388
+ .segment { min-width: 3px; border-radius: 4px; color: var(--bg); font: 600 12px/1 var(--font-mono);
379
389
  padding-left: 8px; display: flex; align-items: center; overflow: hidden; outline: none; }
380
- .segment.dark-ink { color: #0b0b0b; }
390
+ .segment.other { color: var(--text); }
381
391
  .segment:hover, .segment:focus-visible { filter: brightness(1.08); }
382
392
  .table-head { display: flex; align-items: end; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
383
- .search { background: var(--page); color: var(--text); border: 1px solid var(--border); border-radius: 8px;
384
- padding: 7px 10px; font: inherit; min-width: 220px; }
385
- .table-scroll { overflow-x: auto; margin-top: 12px; }
386
- table { width: 100%; border-collapse: collapse; font-size: 13px; }
387
- th { text-align: left; font-weight: 600; color: var(--text-2); font-size: 12px; padding: 8px 10px;
388
- border-bottom: 1px solid var(--axis); cursor: pointer; user-select: none; white-space: nowrap; }
393
+ .search { background: var(--bg); color: var(--text); border: 1px solid var(--line-strong); border-radius: 8px;
394
+ padding: 8px 12px; font: 13px/1.4 var(--font-mono); min-width: 240px; }
395
+ .search::placeholder { color: var(--text-dim); }
396
+ .search:focus-visible { border-color: var(--blue); }
397
+ .table-scroll { overflow-x: auto; margin-top: 14px; }
398
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
399
+ th { text-align: left; font: 500 12px/1.4 var(--font-mono); text-transform: lowercase; color: var(--text-dim);
400
+ padding: 8px 10px; border-bottom: 1px solid var(--line-strong); cursor: pointer; user-select: none; white-space: nowrap; }
401
+ th:hover, th[aria-sort] { color: var(--text); }
389
402
  th.num { text-align: right; }
390
- th[aria-sort]::after { content: " ↓"; color: var(--muted); }
403
+ th[aria-sort]::after { content: " ↓"; color: var(--red); }
391
404
  th[aria-sort="ascending"]::after { content: " ↑"; }
392
- td { padding: 9px 10px; border-bottom: 1px solid var(--grid); vertical-align: top; }
393
- td.num { text-align: right; font-variant-numeric: tabular-nums; }
405
+ td { padding: 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
406
+ td.num { text-align: right; font: 13px/1.6 var(--font-mono); font-variant-numeric: tabular-nums; }
394
407
  tr:hover td { background: var(--wash); }
395
- .desc { color: var(--muted); font-size: 12px; max-width: 360px; overflow: hidden; text-overflow: ellipsis;
408
+ td a { font-weight: 600; }
409
+ .desc { color: var(--text-dim); font-size: 13px; max-width: 360px; overflow: hidden; text-overflow: ellipsis;
396
410
  white-space: nowrap; }
397
- .badge { font-size: 10px; color: var(--text-2); border: 1px solid var(--border); border-radius: 999px;
398
- padding: 0 6px; margin-left: 6px; vertical-align: 1px; }
399
- .nowrap { white-space: nowrap; }
411
+ .badge { font: 600 10px/1.3 var(--font-mono); letter-spacing: .04em; color: var(--text-dim); background: var(--bg-elev);
412
+ border: 1px solid var(--line); border-radius: 4px; padding: 2px 6px; margin-left: 8px; vertical-align: 2px; }
413
+ .nowrap { white-space: nowrap; font: 13px/1.6 var(--font-mono); color: var(--text-dim); }
400
414
  .spark-cell { width: 110px; }
401
415
  .spark { width: 100px; height: 24px; display: block; }
402
- .spark polyline { fill: none; stroke: var(--series-1); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
403
- .notice { margin-top: 16px; color: var(--text-2); font-size: 13px; }
404
- .notice summary { cursor: pointer; }
416
+ .spark polyline { fill: none; stroke: var(--blue); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
417
+ .notice { margin-top: 16px; color: var(--text-dim); font-size: 14px; }
418
+ .notice summary { cursor: pointer; color: var(--text); }
419
+ .notice summary::marker { color: var(--yellow); }
405
420
  .notice li { margin: 4px 0; }
406
- footer { color: var(--muted); font-size: 12px; margin-top: 24px; }
407
- #tooltip { position: fixed; z-index: 10; pointer-events: none; background: var(--surface); color: var(--text);
408
- border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: 12px;
409
- box-shadow: 0 4px 16px rgba(0,0,0,0.12); max-width: 280px; }
421
+ footer { color: var(--text-dim); font-size: 13px; margin-top: 40px; padding-top: 24px; border-top: 1px solid var(--line); }
422
+ footer p { margin: 0 0 20px; max-width: 80ch; }
423
+ .foot-row { display: flex; align-items: center; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap;
424
+ font: 13px/1.4 var(--font-mono); }
425
+ .brand { display: inline-flex; align-items: center; gap: 12px; color: var(--text); font: 700 15px/1 var(--font-display);
426
+ letter-spacing: -0.01em; }
427
+ /* Layered Core mark: the front square with a red core; offset shadows draw the yellow and blue layers behind it. */
428
+ .mark { --s: 18px; --b: 2.25px; --o: 4.5px; --core: 6px;
429
+ width: var(--s); height: var(--s); flex: none; margin: calc(var(--o) * 2) calc(var(--o) * 2) 0 0;
430
+ display: grid; place-items: center; background: var(--bg); border: var(--b) solid var(--text);
431
+ border-radius: calc(var(--s) * 0.214);
432
+ 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),
433
+ 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); }
434
+ .mark::after { content: ""; width: var(--core); height: var(--core); background: var(--red); border-radius: 1px; }
435
+ #tooltip { position: fixed; z-index: 10; pointer-events: none; background: var(--bg-elev); color: var(--text);
436
+ border: 1px solid var(--line-strong); border-radius: 8px; padding: 8px 10px; font: 12px/1.5 var(--font-mono);
437
+ max-width: 300px; }
410
438
  #tooltip .tt-row { display: flex; align-items: center; gap: 6px; }
411
439
  #tooltip .tt-val { margin-left: auto; padding-left: 12px; font-variant-numeric: tabular-nums; font-weight: 600; }
412
440
  @media (max-width: 860px) {
@@ -414,31 +442,20 @@ footer { color: var(--muted); font-size: 12px; margin-top: 24px; }
414
442
  .tiles { grid-template-columns: repeat(2, 1fr); }
415
443
  }
416
444
  @media (max-width: 520px) {
417
- .wrap-page { padding: 20px 16px 48px; }
418
- .hero-value { font-size: 48px; }
445
+ .wrap-page { padding: 28px 16px 48px; }
446
+ .hero-value { font-size: 52px; }
419
447
  .search { min-width: 0; width: 100%; }
420
- h1 { font-size: 20px; }
421
- .avatar { width: 40px; height: 40px; }
422
- .theme-toggle { padding: 5px 9px; font-size: 12px; }
448
+ h1 { font-size: 28px; }
449
+ .avatar { width: 44px; height: 44px; }
423
450
  .x-tick.alt { display: none; }
424
451
  }
452
+ @media (prefers-reduced-motion: reduce) {
453
+ * { transition: none !important; }
454
+ }
425
455
  """
426
456
 
427
457
  JS = r"""
428
458
  (() => {
429
- const root = document.documentElement;
430
- const toggle = document.querySelector('.theme-toggle');
431
- const prefersDark = () => matchMedia('(prefers-color-scheme: dark)').matches;
432
- const current = () => root.dataset.theme || (prefersDark() ? 'dark' : 'light');
433
- try { const saved = localStorage.getItem('gitpulse-theme'); if (saved) root.dataset.theme = saved; } catch (e) {}
434
- const label = () => { toggle.textContent = current() === 'dark' ? 'Light mode' : 'Dark mode'; };
435
- toggle.addEventListener('click', () => {
436
- root.dataset.theme = current() === 'dark' ? 'light' : 'dark';
437
- try { localStorage.setItem('gitpulse-theme', root.dataset.theme); } catch (e) {}
438
- label();
439
- });
440
- label();
441
-
442
459
  const tip = document.getElementById('tooltip');
443
460
  const fmt = (n) => n.toLocaleString();
444
461
  const show = (html, x, y) => {
@@ -530,17 +547,23 @@ TEMPLATE = """<!doctype html>
530
547
  <meta charset="utf-8" />
531
548
  <meta name="viewport" content="width=device-width, initial-scale=1" />
532
549
  <title>{title}</title>
550
+ <meta name="theme-color" content="#0a0d12" />
551
+ <link rel="icon" type="image/svg+xml" href="{icon}" />
552
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
553
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
554
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" />
533
555
  <style>{css}</style>
534
556
  </head>
535
557
  <body>
536
558
  <div class="wrap-page">
559
+ <div class="spectrum" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span></div>
537
560
  <header>
538
561
  {avatar}
539
562
  <div>
563
+ <div class="eyebrow">gitpulse</div>
540
564
  <h1><a href="{owner_url}" target="_blank" rel="noopener">{display}</a></h1>
541
565
  <div class="sub">@{owner} · {repo_count} repositories · traffic {window} · generated {generated}</div>
542
566
  </div>
543
- <button class="theme-toggle" type="button">Dark mode</button>
544
567
  </header>
545
568
 
546
569
  <section class="overview">
@@ -611,9 +634,12 @@ TEMPLATE = """<!doctype html>
611
634
  {missing}
612
635
 
613
636
  <footer>
614
- Generated by GitPulse. GitHub reports aggregate traffic for roughly the last 14 days and never identifies
615
- individual visitors. Unique counts are summed per repository and per day, so one person can be counted
616
- more than once.
637
+ <p>GitHub reports aggregate traffic for roughly the last 14 days and never identifies individual visitors.
638
+ Unique counts are summed per repository and per day, so one person can be counted more than once.</p>
639
+ <div class="foot-row">
640
+ <a class="brand" href="https://fullstackspectrum.com" target="_blank" rel="noopener"><span class="mark"></span><span>Full-Stack-Spectrum</span></a>
641
+ <a href="https://github.com/fullstackspectrum/gitpulse" target="_blank" rel="noopener">generated by gitpulse ↗</a>
642
+ </div>
617
643
  </footer>
618
644
  </div>
619
645
  <div id="tooltip" role="tooltip" hidden></div>