gitpulse-analytics 0.2.0__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.
@@ -11,5 +11,6 @@ venv/
11
11
 
12
12
  # Generated dashboards
13
13
  gitpulse-*.html
14
+ reports/
14
15
  dist/
15
16
  build/
@@ -0,0 +1,82 @@
1
+ # Changelog
2
+
3
+ All notable changes to GitPulse are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
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
+
31
+ ## [0.2.1] - 2026-11-03
32
+
33
+ ### Security
34
+
35
+ - The Docker image is now built on the Alpine variant of
36
+ [Docker Hardened Images](https://dhi.io) for Python instead of `python:3.13-slim`.
37
+ Trivy findings drop from 193 (50 High, none fixable in Debian) to 0.
38
+ - The runtime image has no shell or package manager. GitPulse runs from its own
39
+ virtualenv containing only GitPulse and its dependencies, with no pip.
40
+ - `urllib3` is 2.8.0 in the Docker image and the Homebrew formula, fixing
41
+ CVE-2026-97687, CVE-2026-97688, and CVE-2026-97689.
42
+
43
+ ### Changed
44
+
45
+ - The container runs as the non-root UID 65532 instead of 1000.
46
+ - The Docker image is 105 MB, down from 227 MB.
47
+
48
+ ### Upgrading
49
+
50
+ If you kept snapshot history in a Docker volume or bind mount with 0.2.0, its files
51
+ belong to UID 1000. Saving with 0.2.1 then fails with
52
+ `attempt to write a readonly database`. Give the files to the new user once; your
53
+ history is kept:
54
+
55
+ ```sh
56
+ docker run --rm -v gitpulse-data:/data alpine chown -R 65532:65532 /data
57
+ ```
58
+
59
+ For a bind mount, run `sudo chown -R 65532:65532 DIR` on the host, or keep the old UID
60
+ with `--user 1000:1000`.
61
+
62
+ ## [0.2.0] - 2026-11-03
63
+
64
+ First public release.
65
+
66
+ ### Added
67
+
68
+ - `gitpulse org ORG` builds a dashboard for a whole organization or user account. It
69
+ prints a Rich summary in the terminal and writes a self-contained HTML dashboard
70
+ with daily traffic, the most viewed and most starred repositories, languages,
71
+ referrers, release downloads, and a sortable, filterable table of every repository.
72
+ - Per-repository commands: `repos`, `traffic`, `snapshot`, `history`, and `downloads`.
73
+ - Local SQLite snapshots to keep traffic history beyond GitHub's 14-day window.
74
+ - `gitpulse --version`.
75
+ - Releases on PyPI as `gitpulse-analytics`, in the
76
+ [`fullstackspectrum/gitpulse`](https://github.com/fullstackspectrum/homebrew-gitpulse)
77
+ Homebrew tap, and as the multi-arch `fullstackspectrum/gitpulse` Docker image.
78
+
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
81
+ [0.2.1]: https://github.com/fullstackspectrum/gitpulse/compare/v0.2.0...v0.2.1
82
+ [0.2.0]: https://github.com/fullstackspectrum/gitpulse/releases/tag/v0.2.0
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitpulse-analytics
3
- Version: 0.2.0
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
7
- Project-URL: Changelog, https://github.com/fullstackspectrum/gitpulse/releases
7
+ Project-URL: Changelog, https://github.com/fullstackspectrum/gitpulse/blob/main/CHANGELOG.md
8
8
  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,13 +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
+
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
+ <br>
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)
34
44
 
35
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.
36
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
+
37
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.
38
50
 
39
- ![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)
40
52
 
41
53
  ## Requirements
42
54
 
@@ -75,7 +87,7 @@ python -m pip install gitpulse-analytics
75
87
  docker run --rm -e GITHUB_TOKEN -v "$PWD:/data" fullstackspectrum/gitpulse org ORG
76
88
  ```
77
89
 
78
- The container runs as UID 1000. On Linux, if your user can't write the mounted directory as that UID, add `--user "$(id -u):$(id -g)"`.
90
+ The image is built on a [Docker Hardened Image](https://dhi.io) (Alpine), so it has no shell or package manager and runs as the non-root UID 65532. On Linux, if that UID can't write the mounted directory, add `--user "$(id -u):$(id -g)"`.
79
91
 
80
92
  **From source**, for development:
81
93
 
@@ -120,9 +132,7 @@ It also writes a self-contained HTML dashboard to `gitpulse-ORG.html` in the cur
120
132
  - The most viewed and most starred repositories, a language breakdown, top referrers, and release downloads by repository
121
133
  - A sortable, filterable table of every repository with a 14-day views sparkline
122
134
 
123
- 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.
124
-
125
- ![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.
126
136
 
127
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.
128
138
 
@@ -167,7 +177,8 @@ Run tests with `pytest` and lint with `ruff check .`.
167
177
 
168
178
  ### Releasing
169
179
 
170
- 1. Bump `__version__` in `src/gitpulse/__init__.py` and merge to `main`.
171
- 2. Publish a GitHub release with the tag `vX.Y.Z` matching that version.
180
+ 1. Bump `__version__` in `src/gitpulse/__init__.py`.
181
+ 2. In [CHANGELOG.md](CHANGELOG.md), move the `Unreleased` entries under a new `X.Y.Z` heading with the date, and update the comparison links at the bottom. Merge to `main`.
182
+ 3. Publish a GitHub release with the tag `vX.Y.Z` matching that version, using that version's changelog section as the release notes.
172
183
 
173
184
  The `Release` workflow checks that the tag matches the version, then publishes to PyPI. After that it pushes the multi-arch Docker image and updates the formula in [homebrew-gitpulse](https://github.com/fullstackspectrum/homebrew-gitpulse). A release marked as a pre-release goes to PyPI and to Docker Hub under its exact version tag only, without moving `latest` or the Homebrew formula. The formula template lives in `packaging/homebrew/`; edit it there, not in the tap.
@@ -1,10 +1,22 @@
1
- # GitPulse
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
+ <br>
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)
2
12
 
3
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.
4
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
+
5
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.
6
18
 
7
- ![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)
8
20
 
9
21
  ## Requirements
10
22
 
@@ -43,7 +55,7 @@ python -m pip install gitpulse-analytics
43
55
  docker run --rm -e GITHUB_TOKEN -v "$PWD:/data" fullstackspectrum/gitpulse org ORG
44
56
  ```
45
57
 
46
- The container runs as UID 1000. On Linux, if your user can't write the mounted directory as that UID, add `--user "$(id -u):$(id -g)"`.
58
+ The image is built on a [Docker Hardened Image](https://dhi.io) (Alpine), so it has no shell or package manager and runs as the non-root UID 65532. On Linux, if that UID can't write the mounted directory, add `--user "$(id -u):$(id -g)"`.
47
59
 
48
60
  **From source**, for development:
49
61
 
@@ -88,9 +100,7 @@ It also writes a self-contained HTML dashboard to `gitpulse-ORG.html` in the cur
88
100
  - The most viewed and most starred repositories, a language breakdown, top referrers, and release downloads by repository
89
101
  - A sortable, filterable table of every repository with a 14-day views sparkline
90
102
 
91
- 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.
92
-
93
- ![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.
94
104
 
95
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.
96
106
 
@@ -135,7 +145,8 @@ Run tests with `pytest` and lint with `ruff check .`.
135
145
 
136
146
  ### Releasing
137
147
 
138
- 1. Bump `__version__` in `src/gitpulse/__init__.py` and merge to `main`.
139
- 2. Publish a GitHub release with the tag `vX.Y.Z` matching that version.
148
+ 1. Bump `__version__` in `src/gitpulse/__init__.py`.
149
+ 2. In [CHANGELOG.md](CHANGELOG.md), move the `Unreleased` entries under a new `X.Y.Z` heading with the date, and update the comparison links at the bottom. Merge to `main`.
150
+ 3. Publish a GitHub release with the tag `vX.Y.Z` matching that version, using that version's changelog section as the release notes.
140
151
 
141
152
  The `Release` workflow checks that the tag matches the version, then publishes to PyPI. After that it pushes the multi-arch Docker image and updates the formula in [homebrew-gitpulse](https://github.com/fullstackspectrum/homebrew-gitpulse). A release marked as a pre-release goes to PyPI and to Docker Hub under its exact version tag only, without moving `latest` or the Homebrew formula. The formula template lives in `packaging/homebrew/`; edit it there, not in the tap.
@@ -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",
@@ -38,7 +38,7 @@ dev = ["pytest>=8,<9", "ruff>=0.6,<1"]
38
38
  [project.urls]
39
39
  Homepage = "https://github.com/fullstackspectrum/gitpulse"
40
40
  Issues = "https://github.com/fullstackspectrum/gitpulse/issues"
41
- Changelog = "https://github.com/fullstackspectrum/gitpulse/releases"
41
+ Changelog = "https://github.com/fullstackspectrum/gitpulse/blob/main/CHANGELOG.md"
42
42
 
43
43
  [project.scripts]
44
44
  gitpulse = "gitpulse.cli:app"
@@ -50,7 +50,7 @@ path = "src/gitpulse/__init__.py"
50
50
  packages = ["src/gitpulse"]
51
51
 
52
52
  [tool.hatch.build.targets.sdist]
53
- include = ["src", "tests", "README.md", "LICENSE"]
53
+ include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
54
54
 
55
55
  [tool.pytest.ini_options]
56
56
  pythonpath = ["src"]
@@ -1,3 +1,3 @@
1
1
  """Local GitHub repository analytics."""
2
2
 
3
- __version__ = "0.2.0"
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>